Chapter 3: UI and Navigation
What is UI & UX?
Before we write any code, it's critical to understand these two terms. They are often used together, but they mean very different things.
- UI (User Interface): This is the **"what"** and **"how it looks"**. It's everything the user can see and interact with—the buttons, the text, the images, the colors, the layout. A beautiful button is good UI.
- UX (User Experience): This is the **"why"** and **"how it feels"**. It's the *overall experience* a user has while using your app. Is it easy to use? Is it confusing? Does it solve their problem efficiently? A button that is easy to find and does what the user *expects* it to do is good UX.
A beautiful app (good UI) that is confusing to use (bad UX) will fail. A simple-looking app (okay UI) that is incredibly helpful and easy to use (great UX) will succeed. Your goal is to be great at both.
The Two Worlds of Android UI: XML vs. Jetpack Compose
This is the most important concept in modern Android development. For over a decade, Android UIs were built using **XML (eXtensible Markup Language)**. This is called the **"Imperative"** approach.
In 2019, Google introduced **Jetpack Compose**, a brand-new, modern UI toolkit. This is called the **"Declarative"** approach. It is the future of Android development, and it is what we will focus on first.
Why learn both? Because 99% of companies still have existing apps written in XML. You will need to maintain and add features to this "legacy" code. But all *new* apps and *new* features are being built with Jetpack Compose. A professional developer must understand both worlds.
Imperative (XML) - "The How"
- You create a layout in XML (
activity_main.xml) with a<TextView>and a<Button>. - In your Kotlin code (
MainActivity.kt), you get a *reference* to those UI elements (e.g.,binding.myTextView). - When the state changes (e.g., user clicks a button), you *manually tell the UI how to change*:
binding.myTextView.text = "You clicked me!". - You are the boss, "micromanaging" every UI element.
Declarative (Compose) - "The What"
- You write a Kotlin function (a
@Composable) that *describes* your UI based on a state. - You tell Compose:
Text(text = myStateVariable). - When the state variable changes (e.g.,
myStateVariable = "You clicked me!"), Compose **automatically** and intelligently redraws *only* the parts of the screen that changed. - You just "declare" what the UI *should* look like for a given state, and Compose handles the "how." This is faster, less buggy, and much more powerful.
This roadmap will teach you the modern, Jetpack Compose way first. Then, we will cover the legacy XML system so you are prepared for any job.
Part 1: The Modern UI: Jetpack Compose
Jetpack Compose is Google's modern, declarative UI toolkit for building native Android UI. It simplifies and accelerates UI development. To use it, you must select "Empty Compose Activity" when creating a new project in Android Studio.
Core Concept 1: Composable Functions
In Compose, UI elements are built using functions. A function that describes a piece of UI is marked with the @Composable annotation. This tells the Compose compiler that this function's job is to convert data into UI.
// Import statements for Composables
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
// This is a Composable function.
// Notice the @Composable annotation.
// Notice the PascalCase naming (like a Class).
@Composable
fun Greeting(name: String) {
// Text() is a built-in Composable that displays text.
Text(text = "Hello, $name!")
}
// The @Preview annotation lets you see this Composable
// in the "Split" or "Design" view of Android Studio
// without running the app on an emulator.
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
// You can call your Composables from other Composables.
Greeting(name = "MSMAXPRO")
}
@Composable: This annotation is magic. It tells the compiler to treat this function differently, allowing it to be part of the UI tree and to be "recomposed" (redrawn) when its data changes.@Preview: This is your best friend for fast UI development. It adds a preview window in Android Studio. When you change"MSMAXPRO"to"Aman"and save, the preview updates instantly without needing to rebuild and run the entire app.- Recomposition: When the
namevariable changes, Compose is smart enough to *only* call theGreetingfunction again ("recompose" it) and not the entire screen.
Core Concept 2: Basic Composables (Text, Button, Image)
Compose provides a library of pre-built UI elements (like HTML tags).
Text
Displays a string of text. It is highly customizable.
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.sp
@Composable
fun StyledText() {
Text(
text = "This is a styled text.",
color = Color.Green,
fontSize = 24.sp, // 'sp' (Scale-independent Pixels) is for text size
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
}
Button
A clickable button. The most important parts are onClick (a lambda for what to do when clicked) and the content (usually a Text).
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TextButton
import android.util.Log
@Composable
fun MyButtons() {
// 1. Standard filled button
Button(onClick = {
Log.d("MyButton", "Clicked filled button!")
}) {
Text("Click Me")
}
// 2. Outlined (hollow) button
OutlinedButton(onClick = { /* ... */ }) {
Text("Save")
}
// 3. Text-only button
TextButton(onClick = { /* ... */ }) {
Text("Cancel")
}
}
Image
Displays an image. You provide a painter (usually from your R.drawable resources) and a contentDescription (for accessibility).
import androidx.compose.foundation.Image
import androidx.compose.ui.res.painterResource
import com.codewithmsmaxpro.myapp.R // Your app's R file
@Composable
fun LogoImage() {
Image(
painter = painterResource(id = R.drawable.my_logo),
contentDescription = "My App Logo" // Very important for screen readers!
)
}
Core Concept 3: Layouts (Column, Row, Box)
These are the "divs" of Compose. They are composables that arrange *other* composables.
Column
Arranges its children vertically, one after another.
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.ui.Alignment
@Composable
fun MyColumn() {
Column(
verticalArrangement = Arrangement.spacedBy(10.dp), // Add 10.dp space between items
horizontalAlignment = Alignment.CenterHorizontally // Center items horizontally
) {
Text("First Item")
Text("Second Item")
Text("Third Item")
}
}
Row
Arranges its children horizontally, side-by-side.
import androidx.compose.foundation.layout.Row
@Composable
fun MyRow() {
Row(
horizontalArrangement = Arrangement.SpaceAround, // Spread items out
verticalAlignment = Alignment.CenterVertically // Center items vertically
) {
Text("Left")
Button(onClick = {}) { Text("Center") }
Text("Right")
}
}
Box
Allows you to stack children on top of each other (like z-index). It's also used for aligning a single item in a specific spot.
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@Composable
fun MyBox() {
Box(modifier = Modifier.fillMaxSize()) {
// This image fills the whole box
Image(/* ... */)
// This button sits on top, in the bottom-right corner
Button(
onClick = {},
modifier = Modifier.align(Alignment.BottomEnd)
) {
Text("Save")
}
}
}
Core Concept 4: The Modifier (The CSS of Compose)
The Modifier is one of the most powerful ideas in Compose. It is a parameter you pass to *almost every* Composable to change its look, feel, or behavior. You "chain" modifiers together, one after another.
Important: The order of modifiers matters! .padding(10.dp).background(Color.Red) is *different* from .background(Color.Red).padding(10.dp).
import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.draw.clip
@Composable
fun ModifiedText() {
Text(
text = "Hello!",
modifier = Modifier
.padding(16.dp) // Add 16dp padding on all sides
.background(Color.Blue) // Add a blue background
.padding(24.dp) // Add *more* padding (outside the blue)
.fillMaxWidth() // Make it take the full width
.height(200.dp) // Set a fixed height
)
}
@Composable
fun CircularImage() {
Image(
painter = painterResource(R.drawable.my_profile_pic),
contentDescription = "Profile Picture",
modifier = Modifier
.size(100.dp) // Set size to 100x100 dp
.clip(CircleShape) // Clip the image to a circle
.border(2.dp, Color.Green, CircleShape) // Add a green border
)
}
Core Concept 5: State & Recomposition (The "Brain")
This is the magic of Compose. In the "Imperative" (XML) world, *you* had to find the TextView and call .setText(). In the "Declarative" (Compose) world, you just *tell Compose what to show* based on a variable, and Compose handles the rest.
This "variable" is called **State**. To make a variable a "State" that Compose can watch, you use mutableStateOf.
But there's a problem. If you put a normal variable inside a Composable, it will be reset to its default value every time the function "recomposes" (redraws). To fix this, you must **remember** the state.
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
@Composable
fun Counter() {
// 1. Create a state variable 'count'.
// 'remember' ensures this value survives recompositions.
// 'mutableStateOf(0)' creates the state with a default value of 0.
// 'by' is a Kotlin delegate that simplifies access (no .value needed)
var count by remember { mutableStateOf(0) }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
// 2. The Text Composable "observes" the count state.
Text(text = "You clicked $count times", fontSize = 20.sp)
// 3. The Button *updates* the count state.
Button(onClick = {
count++ // This triggers a recomposition
}) {
Text("Click Me")
}
}
}
**How this works (The Recomposition Loop):**
- The
Counterfunction runs.countis 0.Textshows "You clicked 0 times". - The user clicks the
Button. - The
onClicklambda runs. It changes the value ofcountto 1. - Because
countis aStatevariable, Compose detects this change. - Compose **re-executes** (recomposes) the
Counterfunction. rememberensures thatcountis *not* reset to 0, but instead loads its last value (1).- The
Textcomposable is called again, this time withcount= 1. - The screen updates to show "You clicked 1 times". This is all automatic!
State Hoisting (The Professional Pattern)
The Counter example above is good, but it has a problem: the Counter function *owns* its own state. What if another composable needs to know the count? What if a "Reset" button (outside of Counter) needs to set the count back to 0?
The solution is **State Hoisting** (lifting the state up). This is the most important pattern in Compose. You move the state *up* to the parent composable and pass it *down* as a parameter.
// --- Bad (Stateful) ---
// This component is hard to test and reuse.
@Composable
fun StatefulCounter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Clicked $count times")
}
}
// --- Good (Stateless) ---
// This component is "dumb". It just displays data.
// It is easy to test and reuse.
@Composable
fun StatelessCounter(
count: Int,
onIncrement: () -> Unit // A function passed as a parameter
) {
Button(onClick = onIncrement) {
Text("Clicked $count times")
}
}
// --- Parent Composable ---
// The parent owns the state and "hoists" it.
@Composable
fun AppScreen() {
var count by remember { mutableStateOf(0) }
Column {
// We pass the state *down* and the event *up*.
StatelessCounter(
count = count,
onIncrement = { count++ }
)
// Now the parent can also control the state.
Button(onClick = { count = 0 }) {
Text("Reset Counter")
}
}
}
Core Concept 6: Lists (LazyColumn)
How do you display a list of 1,000 items? You can't just put 1,000 Text composables in a Column, as that would crash your app.
Instead, you use a **LazyColumn**. "Lazy" means it *only* composes and renders the items that are currently visible on the screen. This is Android's modern version of RecyclerView, and it's incredibly simple to use.
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@Composable
fun MyFeed() {
// Imagine this list has 10,000 items from your database
val names = (1..10000).map { "User $it" }
LazyColumn {
// 'items' is the magic function that handles the lazy loading
items(items = names) { name ->
// This code will only run for the items visible on screen
Text(
text = name,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
)
}
}
}
For a horizontal, scrolling list (like "Recommended Movies"), you use LazyRow.
Part 2: Navigation
An app with only one screen isn't very useful. **Navigation** is the logic of moving between screens. Just like with UI, there is a legacy way and a modern way.
The Legacy Way: Activities and Intents
In the "old" Android, every *screen* was a separate Activity (like MainActivity.kt, ProfileActivity.kt). To move from one to another, you used an Intent. This system is heavy, slow, and makes passing data difficult.
// Old way to start a new screen
val intent = Intent(this, ProfileActivity::class.java)
intent.putExtra("USER_ID", 123)
startActivity(intent)
The Modern Way: Jetpack Navigation (Single-Activity)
Today, the best practice is a **Single-Activity Architecture**. Your *entire* app has only **one** Activity (MainActivity). All your different "screens" are actually just Composable functions that are swapped out inside this single Activity.
The **Jetpack Navigation Component** is the library that manages this. It's a "map" for your app.
Core Components of Jetpack Navigation
NavController: The "captain." This is an object that knows all the screens and manages the "back stack." You use this object to tell your app *when* to navigate:navController.navigate("profile").NavHost: The "window." This is a Composable that acts as a container. It's the area on your screen that will be "filled" by your current destination.- Navigation Graph: The "map." This is where you define all your destinations (screens) and the "actions" (paths) between them. You can do this in an XML file (
res/navigation/nav_graph.xml) or purely in Kotlin code.
How to Implement (Simplified Example)
Step 1: Add the Gradle Dependency
In your build.gradle.kts (Module: :app) file, add the dependency for Navigation-Compose.
dependencies {
// ... other dependencies
implementation("androidx.navigation:navigation-compose:2.7.7")
}
Step 2: Define your "Screens" and NavHost
You can define all your screens in one place. We use string "routes" as their unique IDs.
// Your "screens" (just Composable functions)
@Composable
fun HomeScreen(navController: NavController) {
Column {
Text("This is the Home Screen")
Button(onClick = {
// Navigate to the "profile" route
navController.navigate("profile")
}) {
Text("Go to Profile")
}
}
}
@Composable
fun ProfileScreen(navController: NavController) {
Column {
Text("This is the Profile Screen")
Button(onClick = {
// Go back to the previous screen
navController.popBackStack()
}) {
Text("Go Back")
}
}
}
// In your MainActivity.kt, inside setContent { ... }
@Composable
fun MyAppNavigation() {
// 1. Create the NavController
val navController = rememberNavController()
// 2. Create the NavHost (the "window")
NavHost(
navController = navController,
startDestination = "home" // The first screen to show
) {
// 3. Define your destinations (the "map")
composable(route = "home") {
HomeScreen(navController = navController)
}
composable(route = "profile") {
ProfileScreen(navController = navController)
}
}
}
Passing Arguments (e.g., User ID)
This is also much easier. You define the argument in the route itself.
// 1. Define the route with a placeholder
composable(
route = "profile/{userId}", // {userId} is the argument
arguments = listOf(navArgument("userId") { type = NavType.IntType })
) { backStackEntry ->
// 2. Get the argument from the backStackEntry
val userId = backStackEntry.arguments?.getInt("userId")
ProfileScreen(userId = userId)
}
// 3. To navigate, just build the string
Button(onClick = {
val idToPass = 123
navController.navigate("profile/$idToPass")
}) {
Text("Go to Profile 123")
}
Read More about Jetpack Navigation →
Part 3: The Legacy UI: XML & Views
For the next 5-10 years, you will still encounter apps built with the "View" system. This system uses **XML (eXtensible Markup Language)** to define layouts and Kotlin/Java to control them. It's important to understand the basics.
How it Works
- You define your UI in an XML file inside
res/layout/(e.g.,activity_main.xml). - In your Kotlin file (e.g.,
MainActivity.kt), you "inflate" this layout. - You get references to the XML views (like
Button,TextView) using a system called **"View Binding"**. - You manually update these views when your data changes.
Example: `activity_main.xml`
This file defines a "What" (a vertical layout, a text view, and a button).
<?xml version="1.0" encoding="utf-8"?>
<!-- This is a vertical Linear Layout -->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/my_text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
android:textSize="24sp" />
<Button
android:id="@+id/my_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Click Me" />
</LinearLayout>
LinearLayout: A layout that places children in a single line (eitherverticalorhorizontal).android:layout_width/android:layout_height: Every view *must* have this.match_parent: Be as big as my parent.wrap_content: Be just big enough to hold my content.
android:id="@+id/my_text_view": This is the most important part. It gives theTextViewa unique ID so our Kotlin code can find it later.
Connecting to Kotlin with View Binding
The *old* way was findViewById(). It was slow and unsafe (if you typed the ID wrong, it would crash at runtime). The *modern* way is **View Binding**.
Step 1: Enable View Binding in Gradle
In your build.gradle.kts (Module: :app) file, add this inside the android { ... } block:
android {
// ...
buildFeatures {
viewBinding = true
}
}
Sync Gradle. Android Studio will now auto-generate a "Binding" class for each XML layout. (activity_main.xml becomes ActivityMainBinding).
Step 2: Use the Binding Class in your Activity
This is the "How". We manually find and change the views.
// MainActivity.kt
import com.codewithmsmaxpro.myapp.databinding.ActivityMainBinding // Auto-generated
class MainActivity : AppCompatActivity() {
// Declare the binding variable
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 1. "Inflate" the layout and create the binding object
binding = ActivityMainBinding.inflate(layoutInflater)
// 2. Set the content view to the root of the binding
setContentView(binding.root)
// 3. Now you can safely access all views with their IDs
// No more findViewById()! This is 100% type-safe.
binding.myTextView.text = "Hello from View Binding!"
binding.myButton.setOnClickListener {
binding.myTextView.text = "You clicked the button!"
}
}
}
XML Layout Deep Dive: `ConstraintLayout`
LinearLayout is simple, but for complex UIs, it becomes very slow (you end up with "nested layouts"). The modern solution is ConstraintLayout. It allows you to create complex, flat layouts by "constraining" views to each other.
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<Button
android:id="@+id/button_A"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Button A"
<-- Constrain this button's top to the top of the parent -->
app:layout_constraintTop_toTopOf="parent"
<-- Constrain this button's left to the left of the parent -->
app:layout_constraintStart_toStartOf="parent"
android:layout_marginTop="16dp"
android:layout_marginStart="16dp" />
<Button
android:id="@+id/button_B"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Button B"
<-- Constrain this button's top to the *bottom* of Button A -->
app:layout_constraintTop_toBottomOf="@id/button_A"
<-- Align this button's left to the *left* of Button A -->
app:layout_constraintStart_toStartOf="@id/button_A" />
</androidx.constraintlayout.widget.ConstraintLayout>
XML Lists: `RecyclerView` (The Hard Way)
In Compose, we used LazyColumn (which was 10 lines of code). In the XML world, creating a list is *much* more complex. You need to use a RecyclerView, which requires **three** main components:
RecyclerView: The main view that you add to your XML layout.LayoutManager: Tells the list *how* to arrange items (e.g.,LinearLayoutManagerfor a vertical list, orGridLayoutManagerfor a grid).Adapter: The "brain" of the list. This is a class you write that connects your data (e.g., aList<String>) to the UI. It's responsible for creating and "binding" data to each row.ViewHolder: A small helper object inside the Adapter that holds the references to the views for a *single row* (e.g., oneTextView).
This system is complex, but it's *extremely* efficient because it "recycles" the views. Instead of creating 1,000 TextViews for a list of 1,000 items, it only creates ~10 (enough to fill the screen) and then *re-uses* those views, just swapping the data in and out as you scroll.