Skip to content

ComposeNote

q# Jetpack Compose Notes A modern UI framework for Android/multiple platforms.


class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
/* composable function goes here... */
}
}
}
  • Matches the user font preference
  • For Text
  • Matches the user screen pixel (1dp = 1px on baseline)
  • For Non-Text
color = Color.Blue
color = Color(0xFF[hex-code])
val configuration = LocalConfiguration.current
val screenWidthDp = configuration.screenWidthDp

ViewModel Lifecycle

implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0")

ℹ️ You can use ViewModel without dependencies, but get limited lifetime (the ViewModel will be destroyed every time the composable where it’s declared is recomposed or destroyed). So you have to place the viewModel at MainActivity where it won’t be destroyed or recomposed during runtime.

⚠️ Installing the dependencies will extend the lifetime of the viewModel

Add a class that inherits the ViewModel:

class userAuth : ViewModel() {
var starterScreen = mutableStateOf("signin")
fun changeStarterScreen(screen: String) {
starterScreen.value = screen
}
}
@Composable
fun Main(viewModel: userAuth = viewModel()) {
}
val viewModel = userAuth()

Use ViewModel Factory when you need to pass a value into a ViewModel.

Example: passing context to PlayerModel via viewmodel factory

class PlayerViewModelFactory(private val context: Context) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(PlayerModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return PlayerModel(context) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
val viewModel = remember { MyViewModel() }
val viewModel: MyViewModel by viewModels()

To use the by keyword in compose, import:

import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
var count by remember { mutableStateOf(0) }
key(count){
// recompose when the var count change
}

Kind of like LaunchedEffect but allows use in non-composable functions

snapshotFlow { searchQuery }
.filter { it.length > 2 } // Only emit if query is longer than 2 characters
.debounce(500) // Wait 500ms after last change
.collect { query ->
println("Search query: $query")
// Example: Trigger a network search
}
TypeDescriptionExample
Cold FlowStarts emitting only when collected, each collector gets its own streamflow { }, flowOf()
Hot FlowEmits regardless of collectors, shared among all collectorsStateFlow, SharedFlow

Created using flow { } builder. Each collector triggers a new execution.

fun fetchData(): Flow<Int> = flow {
for (i in 1..5) {
delay(1000)
emit(i)
}
}
// Collect in composable
LaunchedEffect(Unit) {
fetchData().collect { value ->
println("Received: $value")
}
}
flow
.map { it * 2 } // Transform values
.filter { it > 10 } // Filter values
.debounce(300) // Wait for pause in emissions
.distinctUntilChanged() // Skip consecutive duplicates
.catch { e -> emit(default) } // Handle errors
.onEach { log(it) } // Side effects
.collect { /* use value */ }
val stateFlow = flow.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
OptionDescription
EagerlyStart immediately, never stop
LazilyStart on first collector, never stop
WhileSubscribed(ms)Start on first collector, stop after last collector + timeout

A hot flow that holds a single value and emits updates to collectors. Always has an initial value.

class MyViewModel : ViewModel() {
private val _uiState = MutableStateFlow("Initial")
val uiState: StateFlow<String> = _uiState.asStateFlow()
fun updateState(newValue: String) {
_uiState.value = newValue
}
}
@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsState()
Text(text = uiState)
}
StateFlowLiveData
Requires initial valueCan be null initially
Kotlin coroutines basedLifecycle-aware by default
collectAsState() in ComposeobserveAsState() in Compose
StateFlowSharedFlow
Always has current valueNo initial value required
.value to read currentNo .value property
Conflates (skips intermediate)Configurable buffer/replay
Best for UI stateBest for events/actions

A hot flow that can emit multiple values and has no initial value. Supports replay and buffer.

class MyViewModel : ViewModel() {
private val _events = MutableSharedFlow<String>()
val events: SharedFlow<String> = _events.asSharedFlow()
fun sendEvent(event: String) {
viewModelScope.launch {
_events.emit(event)
}
}
}
// Replay last 3 values to new collectors
private val _events = MutableSharedFlow<String>(replay = 3)
@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
// Handle one-time events (e.g., show snackbar, navigate)
println("Event received: $event")
}
}
}
  • One-time events (navigation, snackbar, toast)
  • Events that should not be replayed on configuration change
  • Broadcasting to multiple collectors

AlertDialog

AlertDialog(
icon = {/* icon */},
title = {/* title text */},
text = {/* content */},
onDismissRequest = {/* function when try to dismiss */},
confirmButton = {/* button */},
dismissButton = {/* button */}
)

BottomBar

To create a BottomBar, use Scaffold:

Scaffold(modifier = Modifier.fillMaxSize(), bottomBar = {
NavigationBar {
// NavigationBarItem in here ...
}
})

💡 Suggestion: Create a list for storing nav items data then iterate in NavigationBar

NavigationBarItem(
selected = currentScreen == index,
onClick = {
currentScreen = index
nav.navigate(item.title)
},
label = { Text(item.title) },
icon = {
BadgedBox(badge = {
// Badge
}) {
Icon(
imageVector = if (currentScreen == index) item.selectedIcon else item.unselectedIcon,
contentDescription = "image"
)
}
}
)

Badge Badge with content

Badge(content = { Text("12") })

Provides access to constraints inside the composable scope.

BoxWithConstraints {
Image(
painter = painterResource(R.drawable.ocean_cover),
contentDescription = "ocean cover", modifier = Modifier
.size(maxWidth)
.clip(RoundedCornerShape(15.dp))
)
}
  • minWidth
  • minHeight
  • maxWidth
  • maxHeight

ButtonDefaults allows you to customize button colors without rewriting all color settings

Button(onClick = {}, colors = ButtonDefaults.buttonColors(containerColor = Color.Red)) { }

DropdownMenu

var expanded by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.padding(16.dp)
) {
IconButton(onClick = { expanded = !expanded }) {
Icon(Icons.Default.MoreVert, contentDescription = "More options")
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(
text = { Text("Option 1") },
onClick = { /* Do something... */ }
)
DropdownMenuItem(
text = { Text("Option 2") },
onClick = { /* Do something... */ }
)
}
}

⚠️ stickyHeader requires @OptIn(ExperimentalFoundationApi::class)

LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp), // Outer padding
verticalArrangement = Arrangement.spacedBy(8.dp) // Space between items
) {
stickyHeader {
Text(
text = "Sticky Header",
modifier = Modifier
.background(Color.Gray)
.padding(16.dp)
.fillMaxWidth()
)
}
items(items) { item ->
Column {
Text(text = item)
Divider() // Separator between items
}
}
}

Same as LazyColumn or LazyRow but columns is required

LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 200.dp)) {
items(data[0].schedule) { schedule ->
Text(schedule.destination)
}
}
fun Modifier.conditional(condition: Boolean, modifier: Modifier.() -> Modifier): Modifier {
return if (condition) then(modifier(Modifier)) else this
}

Retrieve widget layout (width, height)

Modifier.onGloballyPositioned {
textWidth = it.size.width // Int
}

Use .verticalScroll() or .horizontalScroll()

val state = rememberScrollState()
Column(Modifier.verticalScroll(state)){
}

After binding the state, use .animateScrollTo() to scroll to a location with animation

requiredSizesize
Force to set the size no matter whatRespect parent layout size, if overflow then fit in the parent layout
Box(modifier = Modifier.size(100.dp))
Box(modifier = Modifier.requiredSize(100.dp))

Same as z-index in CSS, allows you to change the layer of composables

Modifier.zIndex(1f)
Modifier.zIndex(2f) // <- higher layer

weight() takes the remaining space of the composables

Modifier.weight(1f)

Weight Example

@Composable
fun WeightExample() {
Column(modifier = Modifier.fillMaxHeight().systemBarsPadding()) {
Row(modifier = Modifier.fillMaxWidth().height(50.dp)) {
Text(text = "Weight 2",
modifier = Modifier.weight(2f)
.background(Color.Red).padding(8.dp))
Text(text = "Weight 2",
modifier = Modifier.weight(2f)
.background(Color.Green).padding(8.dp))
Text(text = "Weight 3",
modifier = Modifier.weight(3f)
.background(Color.Blue).padding(8.dp))
}
Row(modifier = Modifier.fillMaxWidth().height(50.dp)) {
Text(text = "Weight 2",
modifier = Modifier.weight(2f)
.background(Color.Yellow).padding(8.dp))
Text(text = "Weight 1",
modifier = Modifier.weight(1f)
.background(Color.Cyan).padding(8.dp))
}
}
}
Modifier.border(2.dp, Color.Green, CircleShape)

For all bars (bottom nav controller & topbar)

Section titled “For all bars (bottom nav controller & topbar)”
Modifier.windowInsetsPadding(WindowInsets.systemBars)
// or
Modifier.systemBarsPadding()
Modifier.windowInsetsPadding(WindowInsets.statusBars)
Modifier.clip(RoundedCornerShape(20))

RoundedCornerShape() also accepts CircleShape

Modifier.border(1.dp, Color.Red, RoundedCornerShape(20))
Brush.linearGradient(listOf(Color.Red, Color.White))

Text Gradient

Text(
text = "Hello Gradient",
style = TextStyle(
brush = Brush.linearGradient(
colors = listOf(Color.Red, Color.Blue),
),
fontSize = 50.sp
)
)
MethodDirection
linearGradienttopLeft → bottomRight
horizontalGradientLeft → Right
verticalGradientTop → Bottom
Box(
modifier = Modifier
.padding(top = 50.dp)
.size(200.dp)
.shadow(
elevation = 8.dp, // the height of the shadow
shape = RoundedCornerShape(16.dp), // border radius of the box
clip = false // whether not the
)
.background(
Color.White
)
) {
Text("Hello Shadow", modifier = Modifier.align(Alignment.Center))
}
var pager_state = rememberPagerState { 2 } // <- the length of pager
HorizontalPager(
state = pager_state,
modifier = Modifier
.fillMaxWidth()
.height(220.dp),
) { page ->
Image(
painter = painterResource(pager_images[page]),
modifier = Modifier.fillMaxSize(),
contentDescription = "Image of page $page"
)
}
  • pager_state.currentPage - get current page
  • pager_state.animateScrollToPage(<Index>) - scroll to page (Place in coroutine scope)

Requires @OptIn(ExperimentalMaterial3Api::class)

implementation("androidx.compose.material3:material3:1.3.0")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PullToRefreshExample() {
var isRefreshing by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
scope.launch {
isRefreshing = true
// Simulate loading
delay(2000)
isRefreshing = false
}
}
) {
LazyColumn(Modifier.fillMaxSize()) {
items(100) {
Text("Item $it", modifier = Modifier.padding(16.dp))
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomPullToRefresh() {
var isRefreshing by remember { mutableStateOf(false) }
val state = rememberPullToRefreshState()
val scope = rememberCoroutineScope()
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
scope.launch {
isRefreshing = true
delay(2000)
isRefreshing = false
}
},
state = state,
indicator = {
PullToRefreshDefaults.Indicator(
state = state,
isRefreshing = isRefreshing,
modifier = Modifier.align(Alignment.TopCenter),
color = MaterialTheme.colorScheme.primary
)
}
) {
// Content here
}
}
  • isRefreshing: Boolean - Whether the refresh indicator should be shown
  • onRefresh: () -> Unit - Callback when user triggers refresh
  • state: PullToRefreshState - State object to control the pull gesture
  • indicator: @Composable - Custom indicator composable
@Composable
fun RepeatedBackgroundExample() {
val context = LocalContext.current
val bitmap = BitmapFactory.decodeResource(context.resources, R.drawable.your_repeatable_image)
val imageShader = ImageShader(bitmap.asImageBitmap(), TileMode.Repeated, TileMode.Repeated)
val shaderBrush = remember { ShaderBrush(imageShader) }
Box(
modifier = Modifier
.fillMaxSize()
.background(shaderBrush)
) {
// Your content goes here
}
}

SegmentedButtons

var selectedItem by remember { mutableStateOf(0) }
var items = listOf("Kitty", "Squirrel", "Dog")
SingleChoiceSegmentedButtonRow {
items.forEachIndexed { index, item ->
SegmentedButton(
selected = index == selectedItem,
onClick = { selectedItem = index },
shape = SegmentedButtonDefaults.itemShape(index = index, count = items.size)
) {
Text(item)
}
}
}

The segmented buttons only have rounded borders on the left for the first button and on the right for the last button. This requires you to provide the index and count so it knows which shape to render.

Snackbar

Must call snackBar in a coroutineScope

val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
floatingActionButton = {
ExtendedFloatingActionButton(
text = { Text("Show snackbar") },
icon = { Icon(Icons.Filled.Image, contentDescription = "") },
onClick = {
scope.launch {
snackbarHostState.showSnackbar("Snackbar")
}
}
)
}
) { contentPadding ->
// Screen content
}
val result = snackbarHostState
.showSnackbar(
message = "Snackbar",
actionLabel = "Action",
// Defaults to SnackbarDuration.Short
duration = SnackbarDuration.Indefinite
)
when (result) {
SnackbarResult.ActionPerformed -> {
/* Handle snackbar action performed */
}
SnackbarResult.Dismissed -> {
/* Handle snackbar dismissed */
}
}

Returns a LocalTime object

@Composable
fun TimePickerExample() {
var showTimePicker by remember { mutableStateOf(false) }
var selectedTime by remember { mutableStateOf(LocalTime.now()) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Selected Time: ${selectedTime.format(DateTimeFormatter.ofPattern("HH:mm"))}",
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { showTimePicker = true }) {
Text("Pick a Time")
}
if (showTimePicker) {
TimePickerDialog(
onDismissRequest = { showTimePicker = false },
onTimeSelected = { time ->
selectedTime = time
showTimePicker = false
}
)
}
}
}
@Composable
fun TimePickerDialog(
onDismissRequest: () -> Unit,
onTimeSelected: (LocalTime) -> Unit
) {
val timePickerState = rememberTimePickerState(
initialHour = LocalTime.now().hour,
initialMinute = LocalTime.now().minute,
is24Hour = true
)
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = {
onTimeSelected(LocalTime.of(timePickerState.hour, timePickerState.minute))
}) {
Text("OK")
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text("Cancel")
}
},
text = {
TimePicker(state = timePickerState)
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Material3AlarmTimePicker() {
val timePickerState =
rememberTimePickerState(initialHour = 7, initialMinute = 0, is24Hour = true)
var showPicker by remember { mutableStateOf(false) }
Text("${timePickerState.hour}:${timePickerState.minute}")
FilledTonalButton(onClick = { showPicker = !showPicker }) {
Text("Set Alarm Time")
}
Text(text = "Alarm Time: ${timePickerState.hour}:${timePickerState.minute.toString().padStart(2, '0')}")
if (showPicker) {
Dialog(
onDismissRequest = { showPicker = false }
) {
Column(
Modifier
.clip(RoundedCornerShape(20.dp))
.background(Color.White)
.padding(10.dp)
) {
TimePicker(state = timePickerState)
FilledTonalButton(onClick = {
showPicker = false
}) {
Text("Confirm")
}
}
}
}
}

Jetpack Compose provides powerful animation APIs.

Modifier.animateContentSize()
val alpha by animateFloatAsState(
targetValue = if (enabled) 1f else 0.5f,
label = "alpha"
)

Animate the appearance and disappearance of content.

var toggle: Boolean by remember { mutableStateOf(false) }
Column {
Button(onClick = {
toggle = !toggle
}) { Text("Click to Show & Hide") }
AnimatedVisibility(toggle) {
Box(
Modifier
.background(Color.Red)
.size(100.dp)
)
}
}
AnimatedVisibility(
visible = isVisible,
enter = fadeIn() + slideInVertically(),
exit = fadeOut() + slideOutVertically()
) {
Content()
}

Animates content changes with enter/exit transitions.

var count by remember { mutableStateOf(0) }
AnimatedContent(
targetState = count,
transitionSpec = {
// Compare entering and exiting states to decide direction
if (targetState > initialState) {
slideInVertically { -it } + fadeIn() togetherWith
slideOutVertically { it } + fadeOut()
} else {
slideInVertically { it } + fadeIn() togetherWith
slideOutVertically { -it } + fadeOut()
}.using(SizeTransform(clip = false))
},
label = "counter"
) { targetCount ->
Text(text = "$targetCount", fontSize = 48.sp)
}
Button(onClick = { count++ }) { Text("+") }
Button(onClick = { count-- }) { Text("-") }
AnimatedContent(
targetState = isExpanded,
transitionSpec = {
fadeIn(animationSpec = tween(300)) togetherWith
fadeOut(animationSpec = tween(300))
}
) { expanded ->
if (expanded) {
ExpandedContent()
} else {
CollapsedContent()
}
}

A value-based animation method for fine-grained control.

val offsetX = remember { Animatable(0f) }
offsetX.animateTo(
targetValue = maxWidth.toFloat(),
animationSpec = tween(durationMillis = 4000, easing = LinearEasing)
)

snapTo() - Set value instantly without animation

Section titled “snapTo() - Set value instantly without animation”
offsetX.snapTo(0f)
val offsetX = remember { Animatable(0f) }
Box(
Modifier
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
.pointerInput(Unit) {
detectDragGestures(
onDragEnd = {
// Snap back to origin
coroutineScope.launch {
offsetX.animateTo(
targetValue = 0f,
animationSpec = spring()
)
}
},
onDrag = { change, dragAmount ->
change.consume()
coroutineScope.launch {
offsetX.snapTo(offsetX.value + dragAmount.x)
}
}
)
}
)

Create infinite animations that run continuously.

val infiniteTransition = rememberInfiniteTransition(label = "infinite")
// Pulsing animation
val scale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 1.2f,
animationSpec = infiniteRepeatable(
animation = tween(1000),
repeatMode = RepeatMode.Reverse
),
label = "scale"
)
// Rotating animation
val rotation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(2000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "rotation"
)
// Color animation
val color by infiniteTransition.animateColor(
initialValue = Color.Red,
targetValue = Color.Blue,
animationSpec = infiniteRepeatable(
animation = tween(1000),
repeatMode = RepeatMode.Reverse
),
label = "color"
)
Box(
Modifier
.scale(scale)
.rotate(rotation)
.background(color)
.size(100.dp)
)
ModeDescription
RepeatMode.RestartJump back to initial value
RepeatMode.ReverseAnimate back to initial value

Configure how animations behave over time.

SpecDescription
tween()Duration-based with easing
spring()Physics-based spring animation
keyframes()Define values at specific times
snap()Instant change, no animation
infiniteRepeatable()Loops forever
repeatable()Loops N times
animateFloatAsState(
targetValue = 1f,
animationSpec = tween(
durationMillis = 300,
delayMillis = 50,
easing = FastOutSlowInEasing
)
)
animateFloatAsState(
targetValue = if (expanded) 1f else 0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
)
DampingRatioDescription
DampingRatioHighBouncy0.2 - Very bouncy
DampingRatioMediumBouncy0.5 - Medium bounce
DampingRatioLowBouncy0.75 - Low bounce
DampingRatioNoBouncy1.0 - No bounce
StiffnessDescription
StiffnessHighFast
StiffnessMediumMedium
StiffnessMediumLowMedium-slow
StiffnessLowSlow
StiffnessVeryLowVery slow
animateFloatAsState(
targetValue = 1f,
animationSpec = keyframes {
durationMillis = 1000
0f at 0 using LinearEasing
0.5f at 500
1f at 1000
}
)
EasingDescription
LinearEasingConstant speed
FastOutSlowInEasingAccelerate then decelerate (default)
FastOutLinearInEasingFast start, linear end
LinearOutSlowInEasingLinear start, slow end
EaseInOutCubicSmooth start and end

Enter and exit transitions for AnimatedVisibility and AnimatedContent.

fadeIn()
slideIn()
slideInHorizontally()
slideInVertically()
scaleIn()
expandIn()
expandHorizontally()
expandVertically()
fadeOut()
slideOut()
slideOutHorizontally()
slideOutVertically()
scaleOut()
shrinkOut()
shrinkHorizontally()
shrinkVertically()

Use + operator to combine multiple transitions:

AnimatedVisibility(
visible = isVisible,
enter = fadeIn() + slideInVertically(),
exit = fadeOut() + slideOutVertically()
) {
Content()
}

Simple fade transition between content:

var toggle by remember { mutableStateOf(false) }
Column {
Button(onClick = { toggle = !toggle }) {
Text("Toggle to see CrossFade")
}
Crossfade(toggle) { toggle ->
when (toggle) {
true -> Icon(Icons.Default.Add, contentDescription = "")
false -> Icon(Icons.Default.Call, contentDescription = "")
}
}
}

Animate multiple values together:

var toggle: Boolean by remember { mutableStateOf(false) }
val transition = updateTransition(targetState = toggle, label = "")
val color by transition.animateColor(label = "") { s ->
when (s) {
true -> Color.Red
false -> Color.Yellow
}
}
val size by transition.animateDp(label = "") { s ->
when (s) {
true -> 200.dp
false -> 100.dp
}
}
Column {
Button(onClick = {
toggle = !toggle
}) { Text("Click to change Color & Size") }
Box(
Modifier
.background(color)
.size(size)
)
}

use the graphicsLayer modifier to apply compositingStrategy setting

Box(
modifier = Modifier
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
.drawWithContent {
drawContent()
drawRect(
Color.Black,
topLeft = Offset(size.width * clipLevel, 0f),
size = size,
blendMode = BlendMode.Clear
)
}
)

implementation("androidx.navigation:navigation-compose:2.5.2")
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "home"){
composable("home"){
Home()
}
composable("about"){
About()
}
composable("new"){
New()
}
}

The page set in startDestination will be displayed when the NavHost Component is rendered.

  • navigate() - navigate to a screen
  • popBackStack() - navigate to the last screen
Button(
onClick = {
navController.navigate("about")
}
) {
Text("Go to About")
}
Button(
onClick = {
navController.popBackStack()
}
) {
Text("Go Back")
}
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "home") {
composable("home") {
HomeScreen(navController)
}
composable(
route = "detail/{itemId}",
arguments = listOf(navArgument("itemId") { type = NavType.StringType })
) { backStackEntry ->
DetailScreen(itemId = backStackEntry.arguments?.getString("itemId"))
}
}
}
@Composable
fun HomeScreen(navController: NavController) {
Button(onClick = { navController.navigate("detail/123") }) {
Text("Go to Detail")
}
}
@Composable
fun DetailScreen(itemId: String?) {
Text("Item ID: $itemId")
}
navController.navigate("signin") {
// Clear the back stack to prevent the user from navigating back to the home screen
popUpTo("home") { inclusive = true }
}
enum class Screen {
Home, List
}
var navController = rememberNavController()
NavHost(navController = navController, startDestination = Screen.Home.name) {
composable(Screen.Home.name) {
HomeScreen()
}
composable(Screen.List.name) {
ListScreen()
}
}
sealed class Screen(val route: String) {
object Home : Screen("home")
object List : Screen("list")
}
var navController = rememberNavController()
NavHost(navController = navController, startDestination = Screen.Home.route) {
composable(Screen.Home.route) {
HomeScreen()
}
composable(Screen.List.route) {
ListScreen()
}
}

Create a URI handler

val uriHandler = LocalUriHandler.current
uriHandler.openUri("https://eliaschen.dev")

Navigation 3 is a new navigation library designed to work with Compose. With Navigation 3, you have full control over your back stack, and navigating to and from destinations is as simple as adding and removing items from a list.

// In libs.versions.toml
[versions]
nav3 = "1.1.0-alpha02"
[libraries]
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3" }
// In build.gradle.kts
dependencies {
implementation("androidx.navigation3:navigation3-runtime:1.1.0-alpha02")
implementation("androidx.navigation3:navigation3-ui:1.1.0-alpha02")
}

You own the back stack: You, the developer, not the library, own and control the back stack. It’s a simple list which is backed by Compose state. Specifically, Nav3 expects your back stack to be SnapshotStateList<T> where T can be any type you choose. You can navigate by adding or removing items, and state changes are observed and reflected by Nav3’s UI.

Routes must implement NavKey and be @Serializable:

@Serializable
data object Home : NavKey
@Serializable
data object Profile : NavKey
@Serializable
data class Detail(val id: String) : NavKey
@Composable
fun MainNavDisplay() {
val backStack = rememberNavBackStack(Home)
NavDisplay(
modifier = Modifier.fillMaxSize(),
backStack = backStack,
entryProvider = entryProvider {
entry<Home> {
HomeScreen(
onNavigateToProfile = { backStack.add(Profile) }
)
}
entry<Profile> {
ProfileScreen(
onBack = { backStack.removeLastOrNull() }
)
}
entry<Detail> { key ->
DetailScreen(id = key.id)
}
}
)
}
// Navigate forward
backStack.add(Profile)
// Navigate back
backStack.removeLastOrNull()
// Replace current
backStack.removeLastOrNull()
backStack.add(NewScreen)
// Clear and navigate
backStack.clear()
backStack.add(Home)

The rememberNavBackStack composable function is designed to create a back stack that persists across configuration changes and process death.

val backStack = rememberNavBackStack(Home)

Requirements for keys:

  • Implement NavKey interface
  • Have @Serializable annotation

Nav3 introduces the concept of Scene and SceneStrategy, enabling the display of multiple destinations simultaneously. This is particularly useful for creating responsive UIs that adapt to different screen sizes.

// Example: List-Detail layout
NavDisplay(
backStack = backStack,
sceneStrategy = ListDetailSceneStrategy(),
entryProvider = entryProvider {
entry<List> { ListScreen() }
entry<Detail> { DetailScreen() }
}
)

Built-in transition animations are provided. Override at app or screen level:

entry<Profile>(
transitions = NavTransitions(
enter = slideInHorizontally { it },
exit = slideOutHorizontally { -it }
)
) {
ProfileScreen()
}

Example with login flow and bottom bar:

@Composable
fun RootNavDisplay() {
val backStack = rememberNavBackStack(LoginRoute)
NavDisplay(
backStack = backStack,
entryProvider = entryProvider {
entry<LoginRoute> {
LoginScreen(
onLoginSuccess = {
backStack.removeLastOrNull()
backStack.add(MainRoute)
}
)
}
entry<MainRoute> {
MainScreenWithBottomBar()
}
}
)
}
@Composable
fun MainScreenWithBottomBar() {
val bottomBackStack = rememberNavBackStack(TasksRoute)
Scaffold(
bottomBar = { /* Bottom navigation */ }
) {
NavDisplay(
backStack = bottomBackStack,
entryProvider = entryProvider {
entry<TasksRoute> { TasksScreen() }
entry<SettingsRoute> { SettingsScreen() }
}
)
}
}
Nav3Nav2
Developer owns back stackLibrary owns back stack
SnapshotStateList<T>NavController
NavDisplayNavHost
Type-safe Kotlin routesString routes or Safe Args
Built for ComposeOriginally for Fragments

data class Files(
val name: String,
val url: String
)
fun getFiles(): Flow<List<Files>> = flow {
val files: List<Files> = withContext(Dispatchers.IO) {
val url = URL("$host/api/files").openConnection() as HttpURLConnection
url.requestMethod = "GET"
val jsonText = url.inputStream.bufferedReader().use { it.readText() }
val jsonFilesObj = JSONObject(jsonText).getJSONArray("files")
return@withContext List(jsonFilesObj.length()) {
val file = jsonFilesObj.getJSONObject(it)
Files(
file.getString("name"),
file.getString("url")
)
}
}
emit(files)
}.catch {
Log.e("getFiles", it.toString())
emit(emptyList())
}
implementation("com.squareup.okhttp3:okhttp:4.9.3")
val client = OkHttpClient()

Use withContext(Dispatchers.IO) to perform the request in background

val request = Request.Builder().url("<url>").build()
val response = client.newCall(request).execute()
val body = call.body?.string()?: return@withContext emptyList<MusicList>()
val gson = Gson()
val listType = object : TypeToken<List<MusicList>>() {}.type
gson.fromJson(res, listType)
var data by remember { mutableStateOf<List<Music>>(emptyList()) }
withContext(Dispatchers.IO) {
val client = OkHttpClient()
val request = Request.Builder()
.url("https://skills-music-api.eliaschen.dev/music")
.build()
data = client.newCall(request).execute().use { response ->
Gson().fromJson(response.body?.string(), object : TypeToken<List<Music>>() {}.type)
}
}
data class MusicList(
val title: String,
val url: String
)
val gson = Gson()
val client = OkHttpClient()
suspend fun fetchUrl(): List<MusicList> {
return withContext(Dispatchers.IO) {
try {
val request = Request.Builder().url("$host/music").build()
val call = client.newCall(request).execute()
if (call.isSuccessful) {
val res = call.body?.string() ?: return@withContext emptyList<MusicList>()
val listType = object : TypeToken<List<MusicList>>() {}.type
gson.fromJson(res, listType)
} else {
emptyList<MusicList>()
}
} catch (e: Exception) {
emptyList<MusicList>()
}
}
}
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
data class Music(
val title: String,
val url: String
)
interface Api {
@GET("/music")
suspend fun getMusic(): List<Music>
}
object RetrofitInstance {
val api: Api by lazy {
Retrofit.Builder()
.baseUrl("https://skills-music-api.eliaschen.dev") // Example host
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(Api::class.java)
}
}
class MainViewModel : ViewModel() {
private val _musicList = MutableStateFlow<List<Music>>(emptyList())
val musicList: StateFlow<List<Music>> get() = _musicList
// handle error
private val _failed = MutableStateFlow(false)
val failed: StateFlow<Boolean> get() = _failed
init {
fetchMusicList()
}
private fun fetchMusicList() {
viewModelScope.launch {
try {
val musicList = RetrofitInstance.api.getMusic()
_musicList.value = musicList
} catch (e: Exception) {
_failed.value = true
}
}
}
}
@Composable
fun MusicListScreen(viewModel: MainViewModel = viewModel()) {
val musicList by viewModel.musicList.collectAsState()
val failed by viewModel.failed.collectAsState()
if (!failed) {
LazyColumn {
items(musicList) { music ->
Column {
Text(text = music.title)
Text(text = music.url)
}
}
}
} else {
Text("Failed Request!!!")
}
}
import org.json.*
  • JSONArray
  • JSONObject
  • Pass a string with valid JSON format to JSONArray or JSONObject
  • Get a value of key with jsonObject[<index>] or jsonObject.get<DataType>
  • To get a jsonObject in jsonObject use .getJSONObject(<key>)
  • To get a jsonObject in jsonArray use .getJSONObject(<index>)
val jsonString = """
{
"name": "Elias",
"birthday": "2010-05-15",
"favorite": "Coding"
}
""".trimIndent()
val jsonObject = JSONObject(jsonString)
val name = jsonObject.getString("name")
val birthday = jsonObject.getString("birthday")
val favorite = jsonObject.getString("favorite")
println("Name: $name")
println("Birthday: $birthday")
println("Favorite: $favorite")
val jsonString = """
{
"name": "Elias",
"hobbies": ["Coding", "Reading", "Writing"]
}
""".trimIndent()
val jsonObject = JSONObject(jsonString)
val name = jsonObject.getString("name")
val hobbiesArray = jsonObject.getJSONArray("hobbies")
println("Name: $name")
println("Hobbies:")
for (i in 0 until hobbiesArray.length()) {
val hobby = hobbiesArray.getString(i)
println("- $hobby")
}
val name = "Elias"
val age = 14
val hobbies = listOf("Coding", "Reading", "Gaming")
// Create a JSON array from the hobbies list
val hobbiesJsonArray = JSONArray()
for (hobby in hobbies) {
hobbiesJsonArray.put(hobby)
}
// Create the JSON object and put data into it
val jsonObject = JSONObject()
jsonObject.put("name", name)
jsonObject.put("age", age)
jsonObject.put("hobbies", hobbiesJsonArray)
// Output the final JSON string
println(jsonObject.toString(2)) // Pretty print with 2 spaces
val builder = DocumentBuilderFactory.newInstance().newDocumentBuilder()
val inputStream = context.assets.open("sitemap.xml")
// ^if the source is `String` use `byteInputStream`
// EX: val inputStream = aStringData.byteInputStream()
val document = builder.parse(inputStream)
val elements = document.documentElement.getElementsByTagName("loc")
// ^return a list of all tag matched

Use .textContent to get the value in the tag

elements.item(0).textContent
implementation("com.google.code.gson:gson:2.8.9")
data class Music(
val cover: String,
val title: String,
val url: String
) {
fun title(): String = title.replace(".mp3", "")
}

object : TypeToken<List<Music>>() {}.type (provided by gson) is for non-generic type

var data by remember { mutableStateOf<List<Music>>(emptyList()) }
withContext(Dispatchers.IO) {
val jsonString =
URL("https://skills-music-api.eliaschen.dev/music").openConnection().let {
BufferedReader(InputStreamReader(it.getInputStream()))
}.use { it.readText() }
val gson = Gson()
val jsonData = object : TypeToken<List<Music>>() {}.type
data = gson.fromJson(jsonString, jsonData)
}
if(data.isNotEmpty()){
LazyColumn {
items(data) {
Text(it.title())
}
}
}
data class Todo(
@SerializedName("user_id") val userId: Int,
@SerializedName("todo_id") val id: Int,
@SerializedName("task") val title: String,
@SerializedName("done") val completed: Boolean
)

Example: data.json located in assets/

{
"cities": [
{"name": "Taipei", "population": 2646204},
{"name": "Kaohsiung", "population": 2773496},
{"name": "Taichung", "population": 2815100}
]
}
data class City(val name: String, val population: Int)
data class CityList(val cities: List<City>)
val inputStream = context.assets.open("data.json").BufferReader.use {it.readText()}
val gson = Gson()
gson.fromJson(reader, CityList::class.java)

Monitor network connectivity changes in real-time using ConnectivityManager and NetworkCallback.

val networkManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
super.onAvailable(network)
_networkStatus.value = true
}
override fun onLost(network: Network) {
super.onLost(network)
_networkStatus.value = false
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
super.onCapabilitiesChanged(network, networkCapabilities)
// Detect network speed, connection type when network is available
}
}

Note: onCapabilitiesChanged() detects network speed and connection type only when network is available, not when lost.

val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
.build()
networkManager.registerNetworkCallback(networkRequest, networkCallback)

Common options for NetworkCapabilities:

  • NET_CAPABILITY_INTERNET - Network is intended to provide internet access (works in local-only networks)
  • NET_CAPABILITY_VALIDATED - System successfully verified internet connectivity (e.g., pinged a server)
  • NET_CAPABILITY_NOT_RESTRICTED - Network is not restricted (excludes non-standard network environments)
  • TRANSPORT_WIFI - Monitor WiFi connections
  • TRANSPORT_CELLULAR - Monitor mobile data connections
  • TRANSPORT_ETHERNET - Monitor ethernet connections
withContext(Dispatchers.IO) {
val client = OkHttpClient()
val request = Request.Builder()
.url("https://skills-music-api-v2.eliaschen.dev/audio/ocean.mp3")
.build()
val response = client.newCall(request).execute()
response.use {
it.body?.byteStream()?.use { input ->
File(context.getExternalFilesDir(null), "hello.mp3").outputStream()
.use { output ->
input.copyTo(output)
}
}
}
}

ViewModel Required - add kapt plugin at plugins section

plugins {
id("kotlin-kapt")
}
implementation("androidx.room:room-ktx:2.6.1")
kapt("androidx.room:room-compiler:2.6.1")
@Entity(tableName = "user")
data class User(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val name: String, // default column name will be "name"
// Set some custom props with @ColumnInfo like custom ColumnName or DefaultValue
@ColumnInfo(name = "user_name", defaultValue = "Unknown", collate = ColumnInfo.NOCASE) val name: String = "EliasChen",
val age: Int,
val hobby: String
)

To set the default value: val name: String = "EliasChen"

@Dao
interface UserDao {
@Insert
suspend fun insert(user: User)
@Query("SELECT * FROM user")
suspend fun getAllUsers(): List<User>
// use flow to auto get updated data
@Query("SELECT * FROM user")
fun getAllUsers(): Flow<List<User>>
@Delete
suspend fun delete(user: User)
@Query("DELETE FROM user")
suspend fun deleteAll()
@Query("SELECT COUNT(*) FROM user WHERE name = :name")
suspend fun checkNameExists(name: String): Int
}

Parse fun’s data to @Query by adding : at the start of the string. Ex: :name

@Database(entities = [<Schema-name>::class,<Schema-name>::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}

Requires passing context from parent

fun getDatabase(context: Context): AppDatabase {
return Room.databaseBuilder(
context.applicationContext, AppDatabase::class.java, "app_database"
)
.fallbackToDestructiveMigration()
.build()
}

Use .fallbackToDestructiveMigration() to drop old schema when DB migrates

context.deleteDatabase("db")
class TodoViewModel(private val db: AppDB) : ViewModel() {
val allTodo: Flow<List<Todo>> = db.todoDao().select()
fun insert(todo: Todo) = viewModelScope.launch {
db.todoDao().insert(todo)
}
fun deleteTodo(id: Int) = viewModelScope.launch {
db.todoDao().delete(id)
}
fun updateName(newName: String, id: Int) = viewModelScope.launch {
db.todoDao().updateName(newName, id)
}
fun updateDone(done: Boolean, id: Int) = viewModelScope.launch {
db.todoDao().doneTodo(done, id)
}
}
class UserViewModel(private val database: AppDatabase) : ViewModel() {
val users = mutableStateListOf<User>() // for storing data from DB
init {
updateUsers()
}
fun updateUsers() {
viewModelScope.launch {
users.clear()
users.addAll(database.userDao().getAllUsers())
}
}
fun addUser(name: String) {
viewModelScope.launch {
database.userDao().insert(User(name = name))
updateUsers()
}
}
fun deleteUser(user: User) {
viewModelScope.launch {
database.userDao().delete(user)
updateUsers()
}
}
fun deleteAllUsers() {
viewModelScope.launch {
database.userDao().deleteAll()
updateUsers()
}
}
suspend fun checkNameExists(name: String): Boolean {
return database.userDao().checkNameExists(name) > 0
}
}

NOTE: All DB actions must be in a suspend function or a viewModelScope

val database = getDatabase(this)
val userViewModel = UserViewModel(database)

Create database from MainActivity and pass database to the viewModel that interacts with the database

Save small data

// create a preferences
val sharedPref = context.getSharedPreferences("test", Context.MODE_PRIVATE)
// get data
var countSaved = sharedPref.getInt("count", 0)
// set data
sharedPref.edit().putInt("count", <set_value>).apply()

/storage/emulated/0/Android/data/your.package.name/files/

  • Big File (No Limited Space & Required permission)
context.getExternalFilesDir(null) // use this to access External dir

/data/user/0/your.package.name/files/

  • Small File (Limited Space & No permission require)
context.filesDir // use this to access Internal dir

Use the Device Explorer in Android Studio to review/edit all files inside the Android emulator

null means get the root dir path

context.getExternalFilesDir(null)

Get public view asset folders (e.g., Download Folder)

Section titled “Get public view asset folders (e.g., Download Folder)”
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
var myFile = File(context.getExternalFilesDir(null), "ocean.mp3")
myFile.exists()
val customDir = File(context.getExternalFilesDir(null), "customFolder")
if (!customDir.exists()) {
customDir.mkdir()
}
fun getFiles(context: Context): List<File> {
val dir = context.getExternalFilesDir(null)
return dir?.listFiles()?.toList() ?: emptyList()
}
withContext(Dispatchers.IO) {
val file = File(context.getExternalFilesDir(null), "hello.json")
FileWriter(file).use { writer ->
writer.write(Gson().toJson(api))
}
}
File(context.getExternalFilesDir(null), "customFolder")
.outputStream().use { output ->
<Your-InputStream>.copyTo(output)
}
val oldFile = File(context.getExternalFilesDir(null), "ocean.mp3")
val newFile = File(context.getExternalFilesDir(null), "new_ocean.mp3")
oldFile.renameTo(newFile)
File(context.getExternalFilesDir(null), "customFolder").delete()
File(context.getExternalFilesDir(null), "ocean.mp3").toUri()

The Content Provider functions as an intermediary, facilitating access to the private data or files of your application by other applications.

Content Provider

FileProvider (Share files with other apps)

Section titled “FileProvider (Share files with other apps)”
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/paths" />
</provider>

Give other apps permission to access app private external storage

Section titled “Give other apps permission to access app private external storage”

Create res/xml/paths.xml:

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-files-path name="external_dir" path="." />
</paths>
val cameraCaptureUri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
File(context.getExternalFilesDir("image"), "image_${System.currentTimeMillis()}.png")
)

⚠️ Make sure the requested path is defined in the FILE_PROVIDER_PATHS resource, or else you’ll get a security exception.


@Composable
fun VideoPlayer(modifier: Modifier = Modifier) {
var player by remember { mutableStateOf<MediaPlayer?>(null) }
Scaffold() { innerPadding ->
Card(modifier = Modifier.padding(innerPadding)) {
AndroidView(
{
SurfaceView(it).apply {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceChanged(
p0: SurfaceHolder,
p1: Int,
p2: Int,
p3: Int
) {
}
override fun surfaceCreated(p0: SurfaceHolder) {
player = MediaPlayer().apply {
val path = context.assets.openFd("pitch.webm")
val url = "https://youtube.eliaschen.dev/download/Olivia%20Rodrigo%20-%20drivers%20license%20(Official%20Video).mp4"
setDataSource(url)
setDisplay(holder)
prepareAsync()
setOnPreparedListener {
it.start()
}
} }
override fun surfaceDestroyed(p0: SurfaceHolder) {
player?.apply {
stop()
release()
}
}
})
}
}, modifier = Modifier
.aspectRatio(16f / 9f)
)
}
}
}
  • Speed
player.playbackParams = PlaybackParams().apply {
speed = 1.5f
pitch = 1f
}
  • Volume
player.setVolume(0.0f, 1.0f)
implementation("androidx.media3:media3-exoplayer:1.5.1")
implementation("androidx.media3:media3-common:1.5.1")
val player = remember { ExoPlayer.Builder(context).build() }

Add media source with URI and prepare for playing

Section titled “Add media source with URI and prepare for playing”
player.setMediaItem(MediaItem.fromUri("<uri>"))
player.prepare()
  • play() - play the media
  • pause() - pause the media
  • stop() - end the media
  • release() - unload player
DisposableEffect(Unit) {
onDispose {
ExoPlayer.release()
}
}

The playlist in ExoPlayer is just a set of MediaItems

MediaItem.Builder()
.setUri(host + music.url)
.setMediaMetadata( // set some metadata
MediaMetadata.Builder()
.setTitle(music.title())
.setDescription(music.cover)
.build()
)
.build()
player.setMediaItems(mediaItems)

Use a player listener to retrieve realtime metadata from player

player.addListener(object : Player.Listener {
override fun onEvents(player: Player, events: Player.Events) {
// something to do
super.onEvents(player, events)
}
})
// use `player.volume` to get/set volume, sooo easy~
fun adjustVolume(target: Float) {
player.volume.coerceIn(0.0f, 1.0f)
player.volume = target
}
player.repeatMode // get
player.repeatMode = Player.REPEAT_MODE_OFF // no repeat
player.repeatMode = Player.REPEAT_MODE_ONE // for current media item
player.repeatMode = Player.REPEAT_MODE_ALL // for the whole playlist
val context = LocalContext.current
val bitmap = remember {
val file = context.assets.open(<filePath>)
BitmapFactory.decodeStream(file)
}
Image(
bitmap = bitmap.asImageBitmap(), contentDescription = <filePath>
)
@Composable
fun NetworkImage() {
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
try {
bitmap = URL("https://skills-music-api.eliaschen.dev/image/ocean.jpg").openStream()
.use { BitmapFactory.decodeStream(it) } ?: null
} catch (e: Exception) {
bitmap = null
}
}
}
Column {
bitmap?.asImageBitmap()?.let { Image(bitmap = it, contentDescription = "") }
}
}
@Composable
fun NetworkImage(url: String) {
val bitmap = remember { mutableStateOf<android.graphics.Bitmap?>(null) }
LaunchedEffect(url) {
bitmap.value = withContext(Dispatchers.IO) {
OkHttpClient()
.newCall(Request.Builder().url(url).build())
.execute()
.use { response ->
response.body?.bytes()?.let { android.graphics.BitmapFactory.decodeByteArray(it, 0, it.size) }
}
}
}
bitmap.value?.let {
Image(bitmap = it.asImageBitmap(), contentDescription = "Network image")
}
}
var tts by remember { mutableStateOf<TextToSpeech?>(null) }
var ttsReady by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
tts = TextToSpeech(context) { status ->
if (status == TextToSpeech.SUCCESS) {
tts?.language = Locale.US
ttsReady = true
}
}
}
DisposableEffect(Unit) {
onDispose {
tts?.stop()
tts?.shutdown()
}
}
tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, null)

The second parameter decides the TTS engine behavior when there’s new text to speak:

UsageDescription
TextToSpeech.QUEUE_FLUSHSpeak immediately
TextToSpeech.QUEUE_ADDSpeak right after the current speech is done
@Composable
fun VideoFirstFrame() {
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
LaunchedEffect(Unit) {
bitmap = withContext(Dispatchers.IO) {
val retriever = MediaMetadataRetriever()
try {
retriever.setDataSource(videoUrl)
retriever.getFrameAtTime(1000000, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
} catch (e: Exception) {
null
} finally {
retriever.release()
}
}
}
bitmap?.let {
Card(modifier = Modifier.padding(10.dp)) {
Column {
Text(
"Video First Frame",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(10.dp)
) Image(
bitmap = it.asImageBitmap(),
contentDescription = null,
)
}
}
}
}
class AudioRecordModel(private val context: Application) : AndroidViewModel(context) {
private var recoder: MediaRecorder? = null
var isRecording by mutableStateOf(false)
fun start() {
val currentTime = System.currentTimeMillis().let {
SimpleDateFormat("yyyy-MM-dd_HH:mm:ss", Locale.TAIWAN).format(it)
}
val file =
File(context.getExternalFilesDir(Environment.DIRECTORY_MUSIC), "$currentTime.m4a")
recoder = MediaRecorder(context).apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setOutputFile(file)
prepare()
start()
}
isRecording = true
}
fun stop() {
recoder?.apply {
stop()
release()
}
recoder = null
isRecording = false
}
}

Retrieve the AudioManager for later use

val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
// Define which stream you want to control (usually STREAM_MUSIC for media)
val streamType = AudioManager.STREAM_MUSIC
// Get the max volume to calculate a percentage
val maxVolume = audioManager.getStreamMaxVolume(streamType)
// Calculate 50% volume
val targetVolume = maxVolume / 2
// Set the volume
audioManager.setStreamVolume(
streamType,
targetVolume,
AudioManager.FLAG_SHOW_UI // This flag shows the system volume slider overlay
)

or you can set the volume by built-in step

// Increase volume by one step
audioManager.adjustStreamVolume(
AudioManager.STREAM_MUSIC,
AudioManager.ADJUST_RAISE,
AudioManager.FLAG_SHOW_UI
)
// Decrease volume by one step
audioManager.adjustStreamVolume(
AudioManager.STREAM_MUSIC,
AudioManager.ADJUST_LOWER,
AudioManager.FLAG_SHOW_UI
)

Example of how to send a notification at a custom time via Alarm Manager

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

⚠️ Remember to register the receiver in the manifest ℹ️ android.permission.RECEIVE_BOOT_COMPLETED is not necessary if you don’t activate the alarm when the device boots up

Create a BroadcastReceiver for pushing notification

Section titled “Create a BroadcastReceiver for pushing notification”
class AlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
sendNotification(context)
}
private fun sendNotification(context: Context) {
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val notificationId = (100..200).random()
val channelId = "hello_background"
val channel =
NotificationChannel(
channelId,
"hello_background",
NotificationManager.IMPORTANCE_HIGH
)
notificationManager.createNotificationChannel(channel)
val notification = NotificationCompat.Builder(context, channelId)
.setContentTitle("It's time to die")
.setContentText("A test message!!!")
.setSmallIcon(R.drawable.play)
.build()
notificationManager.notify(notificationId, notification)
}
}
@SuppressLint("ScheduleExactAlarm")
fun setExactAlarm(context: Context, triggerTime: Long) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, AlarmReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent)
}

App Shortcut

The maximum actions for an app is 5

Can’t be modified after build

In AndroidManifest add meta data inside activity:

<meta-data android:name="android.app.shortcuts" android:resource="@xml/shortcuts" />

Add actions in res/xml/ for example res/xml/shortcuts.xml

<?xml version="1.0" encoding="utf-8"?>
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<shortcut
android:icon="@drawable/event"
android:shortcutId="events"
android:shortcutShortLabel="@string/event"
android:shortcutLongLabel="@string/event_long"
>
<intent
android:action="android.intent.action.VIEW"
android:targetClass="dev.eliaschen.wsc2022maam.MainActivity"
android:targetPackage="dev.eliaschen.wsc2022maam">
<extra
android:name="shortcut_id"
android:value="events" />
</intent>
</shortcut>
</shortcuts>

android:shortcutShortLabel and android:shortcutLongLabel(optional) has to be a string resource

val customIntent = Intent(
context,
MainActivity::class.java
).apply {
action = Intent.ACTION_VIEW
putExtra("shortcut_id", "events")
}
val shortcut = ShortcutInfoCompat.Builder(context, "id1")
.setShortLabel("Dynamic Event")
.setLongLabel("Open the events")
.setIcon(
IconCompat.createWithResource(
context,
R.drawable.bookmark
)
) .setIntent(customIntent)
.build()
ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)

since we put a extra in intent , just check the extra in MainActivity like you normally do

intent.getStringExtra("shortcut_id")
clipboardManager.setText("Hello, clipboard")
val haptic = LocalHapticFeedback.current
haptic.performHapticFeedback(HapticFeedbackType.LongPress)

in Manifest.xml’s activity tag add following config for link wsapp://edu.ws2022.a2/app/worldskills

<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="edu.ws2022.a2"
android:path="/app/worldskills"
android:scheme="wsapp" />
</intent-filter>

Foreground Service

For MediaPlayback Service, check out the Competition section below.

implementation("androidx.glance:glance-appwidget:1.1.0")
implementation("androidx.glance:glance-material3:1.1.0")
class MyReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = MyGlanceWidget()
}
class MyGlanceWidget : GlanceAppWidget() {
@SuppressLint("RestrictedApi")
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
/* composable function here... */
}
}
}

Create a xml file under res/xml/:

<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/app_name"
android:minWidth="200dp"
android:minHeight="100dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1000" />

⚠️ HINT: android.appwidget.* - the widget is appwidget

<receiver
android:name=".MyReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/music_widget_info" />
</receiver>

BarWidget is a GlanceAppWidget() in this example

In Application Class, declare a scope to run suspend action:

private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

Run the update widget method:

appScope.launch {
BarWidget().updateAll(applicationContext)
}

⚠️ Remember to bind the application class in AndroidManifest.xml and add the widget update method to onCreate() to update the widget when app launches

Under MainActivity:

suspend fun refreshWidget(context: Context) {
BarWidget().updateAll(context)
}

In Composable:

CoroutineScope(Dispatchers.Main).launch {
refreshWidget(this@MainActivity)
}
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel =
NotificationChannel(
"channel_id",
"channel_name",
NotificationManager.IMPORTANCE_HIGH
)
notificationManager.createNotificationChannel(channel)
val notification =
NotificationCompat.Builder(context, channelId)
.setContentTitle("hello")
.setContentText("hello")
.setSmallIcon(R.drawable.play)
.build()
notificationManager.notify(<notificationId: Int>, notification)

Permission Dialog

⚠️ Requires Android Studio version above TIRAMISU: @RequiresApi(Build.VERSION_CODES.TIRAMISU)

Example: Check whether user allows APP to push notification

val hasPermission = ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED

Create a launcher to show the system dialog for requesting permission

Section titled “Create a launcher to show the system dialog for requesting permission”
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) {
Toast.makeText(context, "A permission request has done", Toast.LENGTH_SHORT).show()
}
if (!hasPermission) launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
implementation("androidx.webkit:webkit:1.8.0")

In project explorer under main/assets add a HTML file, then use loadUrl("file:///android_asset/...") to locate it.

NOTE: file:///android_asset points to main/assets

AndroidView(factory = {
WebView(it).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, // for style width and height
ViewGroup.LayoutParams.MATCH_PARENT
)
}
}, update = {
it.loadUrl(<file-path or web url>)
})

To access the internet, enable the permission in AndroidManifest.xml:

<uses-permission android:name="android.permission.INTERNET" />

Calendar is a Java method for converting readable time into a calendar that can be converted to multiple formats.

val calendar = Calendar.getInstance().apply {
timeInMillis = System.currentTimeMillis()
set(Calendar.HOUR_OF_DAY, 7)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
if (timeInMillis <= System.currentTimeMillis()) {
add(Calendar.DAY_OF_MONTH, 1)
}
}
var currentTime by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
while (true) {
currentTime = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date())
delay(1000L)
}
}
LocalDateTime.now().format(
DateTimeFormatter.ofPattern(
"yyyy-MM-dd HH:mm:ss"
)
)
val formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss")
val parsed = LocalDateTime.parse("12/04/2026 14:30:00", formatter) // skip formatter if parse source is ISO time
val millis = parsed
.atZone(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
  • Date only
val formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy")
val parsed = LocalDate.parse("12/04/2026", formatter)
val millis = parsed
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()

Returns a boolean

android.util.Patterns.EMAIL_ADDRESS.matcher(email.value).matches()

Streams

  • InputStream → Read data
  • OutputStream → Write data

The toDp() method is only available in Local

with(LocalDensity.current) {
offsetX.value.toDp()
}
  • content://contacts/people/1 (accesses a specific contact with ID 1)
  • content://media/external/audio/media/123 (points to an audio file with ID 123 in external storage)
  • content://com.android.calendar/events (accesses calendar events)
  • file:///storage/emulated/0/Download/sample.pdf (points to a PDF file in the Download folder)
  • file:///android_asset/index.html (accesses a file in the app’s assets folder)
  • tel:5551234567 (initiates a phone call to the specified number)
  • mailto:user@example.com (opens an email client with the specified address)
  • geo:37.7749,-122.4194 (opens a map at the specified coordinates)
  • myapp://profile/user123 (a custom scheme to open a specific user profile in a custom app)