DEV Community

loading...
Cover image for Jetpack compose #1 DropdownMenu - Weather UI

Jetpack compose #1 DropdownMenu - Weather UI

Mahendran
Android developer, Kotlin - KMP - GraphQL enthusiast
Originally published at mahendranv.github.io Updated on ・4 min read

Introduction

Sharing my experience with jetpack while practicing with the weather - forecast screen(from uptechteam). While surfing dribble this particular shot caught my eye. Minimal - subtle colors also simple layout to implement. Best fit if you want to start with any UI framework.

Alt Text

Anatomy

This page comprise of multiple sections, each will cover different ui element from compose. To make use of stacked layouts (box, row, column), I didn't touch ConstraintLayout in this project.

  1. Top app bar → Popup window
  2. Carousel of weather cards with indicator
  3. List of forecast for the week

Appbar - Spinner

App bar holds a back arrow and a Spinner?!! that helps user to switch between cities. Let's focus on the popup window part.

In the old android system, this is a Spinner component with custom adapter (since we do color updates). This requires layouts defined for both anchor and list item, inflated at some point. Not to say about binding the content and updating the selected element. Enogh with old Views. Let's see how to implement this in Jetpack.

Implementation

Anchor view

Create a Row container that holds the Text & Icon. This will act as anchor for dropdown and ensure clicking both items will show the menu.

Box {
  // Back arrow here
  Row { // Anchor view
    Text(text = "",) // City name label
    Icon(imageVector = Icons.Filled.ArrowDropDown,)
  }
}
Enter fullscreen mode Exit fullscreen mode

Alt Text

Now we don't see any city name displayed in it. We can take a param for the @Compose function and update it. But we expect the name to change while picking a city from menu. Thankfully, we have a nice way of doing this in Compose.

// State variables
var cityName : String by remember {mutableStateOf(cityList[0])}

...

Text(text = cityName,) // City name label
Enter fullscreen mode Exit fullscreen mode

Here, cityName is a local state that update the UI once the value change. We set the initial value to first item in the list.

Alt Text

...

Dropdown menu design

Now we have the AppBar UI ready, let's design dropdown menu.

var expanded by remember { mutableStateOf(false)}

...

Row { // Anchor view
    ... 
    DropdownMenu(
      expanded = expanded, 
      onDismissRequest = {}) {
       // TODO: Popup window content
    }
 }
Enter fullscreen mode Exit fullscreen mode

Here, we introduced a new state called expanded, which controls the visibility of menu. To show the window, we set it to true otherwise false. We know anchor controls the visibility, so add a click listener there.

  Row(Modifier.clickable { // Anchor view
    expanded = !expanded
  })
Enter fullscreen mode Exit fullscreen mode

Alt Text

Running this, we get ripple - nothing more. This is because, we haven't setup the menu. Let's jump to it. Before adding, the actual content, let's experiment with popup content.

DropdownMenu {
    Text("Popup content \nhere", Modifier.padding(24.dp))
}
Enter fullscreen mode Exit fullscreen mode

Alt Text

Now the menu is shown, but we cannot dismiss it. As we saw earlier, the expanded variable is the only thing that can control the visibility.

But.. we toggle it on clicking on anchor. This is because, the click event is not delivered to the anchor (note.. no ripple). A different callback stole it. Let's figure out what to do.

DropdownMenu(expanded = expanded, 
             onDismissRequest = {
               expanded = false
             })
Enter fullscreen mode Exit fullscreen mode

Alt Text

The mandatory callback onDismissRequest , should be implemented to dismiss the menu. Otherwise, we'll endup with a sticky popup like in previous step.

...

Menu item design

We can place any Composable view in side the DropdownMenu. Current case, we need list of Label/Text in a column - each clickable. To keep the article short, I'm not demonstrating with a Column and Text list items. Keep these two points in mind and fast forward to the snippet.

  • DropdownMenu places the given content inside column. So we don't have to add a Column manually. From DropdownMenu source:
   @Composable
   fun DropdownMenu(
       expanded: Boolean,
       onDismissRequest: () -> Unit,
       ...
       properties: PopupProperties = PopupProperties(focusable = true),
       content: @Composable ColumnScope.() -> Unit
   )
Enter fullscreen mode Exit fullscreen mode
  • Likewise, we have DropdownMenuItem container, which is a Row internally - can take care of placing line item's content horizontally [ex. Country flag - Country name]. Also it ensures minimum height / width requirements met as per material design and has a click listener. From DropdownMenuItemContent source:
   @Composable
   internal fun DropdownMenuItemContent(
       onClick: () -> Unit,
       content: @Composable RowScope.() -> Unit
   ) 

   ...
   Row()
   .sizeIn(
     minWidth = DropdownMenuItemDefaultMinWidth,
     maxWidth = DropdownMenuItemDefaultMaxWidth,
     minHeight = DropdownMenuItemDefaultMinHeight)
Enter fullscreen mode Exit fullscreen mode

Let's make use of above containers and cook the list items.

DropdownMenu(...) {
        cityList.forEach { city ->
                DropdownMenuItem(onClick = { }) {
                    Text(city)
                }
            }
}
Enter fullscreen mode Exit fullscreen mode

Alt Text

We get a list with clickable items, but menu not dismissed and value not updated. It's just matter of implementing the onClick in menu-item.

cityList.forEach { city ->
   DropdownMenuItem(onClick = { 
     expanded = false
     cityName = city
   }) {
        Text(city)
      }
}
Enter fullscreen mode Exit fullscreen mode

Alt Text

With bit of style customization to menu item, we can highlight the current city in menu.

val isSelected = city == cityName
val style = if (isSelected) {
    MaterialTheme.typography.body1.copy(
        fontWeight = FontWeight.Bold,
        color = MaterialTheme.colors.secondary
    )
} else {
    MaterialTheme.typography.body1.copy(
        fontWeight = FontWeight.Normal,
        color = MaterialTheme.colors.onSurface
    )
}
Text(text= city, style = style)
Enter fullscreen mode Exit fullscreen mode

Alt Text

...

Conclusion

We can see the benefit of using composable here. In the old view system, Views has it's own saved instance state backed by their ids this had few advantages, yet leaves the system unpredictable in many cases. Devs have to take care of keeping the UI and model in sync.

In compose, data drives the UI. Take a look at cityName, initial value set to the component — and then menu item click updates the variable name - not the Text/Label view. This is called unidirectional data flow. Data paints the UI, and UI updates the model. No UI vs UI communication happens here. That means, we don't take value from the UI — rather state can give it to us.

Discussion (0)

Forem Open with the Forem app