$30 off During Our Annual Pro Sale. View Details »

Let’s build an Android UI with Jetpack Compose

Let’s build an Android UI with Jetpack Compose

The Android UI uses a similar approach for the past decade. Recently, developers use DSL, functional programming more often than a few years ago. The Android UI Toolkit team proposed a new way of creating UI for Android applications.

This presentation covers the following topics:
- Getting Started with Jetpack Compose
- Overview of the layouts available in Jetpack Compose
- Build a set of screens with Jetpack Compose
- Introduction to Theme and Typography in Jetpack Compose
- Introduction to Testing with Jetpack Compose testing library

Alex Zhukovich

April 20, 2020
Tweet

More Decks by Alex Zhukovich

Other Decks in Technology

Transcript

  1. public class CheckBox extends CompoundButton { public CheckBox(Context context) {

    this(context, null); } public CheckBox(Context context, AttributeSet attrs) { this(context, attrs, com.android.internal.R.attr.checkboxStyle); } public CheckBox(Context context, AttributeSet attrs, int defStyleAttr) { this(context, attrs, defStyleAttr, 0); } public CheckBox(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); } @Override public CharSequence getAccessibilityClassName() { return CheckBox.class.getName(); } }
  2. @Composable fun CalendarItem( month: String, date: String, day: String )

    { Surface( modifier = Modifier.preferredSize(80.dp), shape = CircleShape, border = Border(0.5.dp, Color.Gray) ) { Box( gravity = Alignment.TopCenter, modifier = Modifier.padding(4.dp) ) { Column { Text(text = month) Text( text = date, style = TextStyle( fontSize = 24.sp, fontWeight = FontWeight.Bold ) ) Text(text = day) } } } }
  3. @Preview @Composable fun previewDate() { CalendarItem("APR", "20", "Mon") } @Preview

    @Composable fun previewAndroid() { Row(modifier = Modifier.padding(8.dp)) { CalendarItem("APR", "20", "Mon") Spacer(modifier = Modifier.preferredWidth(16.dp)) CalendarItem("APR", "21", "Tue") } }
  4. Calendar { repeat(4) { CalendarWeek { repeat(7) { CalendarItem( month

    = "APR", date = "20", day = "Mon" ) } } } }
  5. Calendar { repeat(4) { CalendarWeek { repeat(7) { CalendarItem( month

    = "APR", date = "20", day = "Mon" ) } } } }
  6. Calendar { repeat(4) { CalendarWeek { repeat(7) { CalendarItem( month

    = "APR", date = "20", day = "Mon" ) } } } }
  7. class MainActivity : AppCompatActivity() { override fun onCreate( savedInstanceState: Bundle?

    ) { super.onCreate(savedInstanceState) setContent { AppContent() } } ... @Composable fun AppContent() { ... } } class MainFragment : Fragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { val view = inflater.inflate( R.layout.fragment_main, container, false ) (view as ViewGroup).setContent { AppContent() } return fragmentView } @Composable fun AppContent() { ... } }
  8. ModelList State @Composable data class Counter( var count: MutableState<Int> =

    mutableStateOf(0) ) @Composable fun Counter(counter: Counter) { Row { Button( onClick = { counter.count.value++ } ) { Text(text = "+") } Text(text = counter.count.value.toString()) Button( onClick = { counter.count.value-- } ) { Text(text = "-") } } }
  9. @Composable ModelList State data class Counter( var count: MutableState<Int> =

    mutableStateOf(0) ) @Composable fun Counter(counter: Counter) { Row { Button( onClick = { counter.count.value++ } ) { Text(text = "+") } Text(text = counter.count.value.toString()) Button( onClick = { counter.count.value-- } ) { Text(text = "-") } } }
  10. var counters = ModelList<Counter>() counters.add(Counter(0)) @Composable fun Counter(counters: MutableState<ModelList<Counter>>) {

    Row { Button(onClick = { val newList = (counter.value).also { it.add(Counter(0)) } counter.value = newList }) { Text(text = "+") } Text(text = counter.value.size.toString()) … } } } @Composable ModelList State
  11. Row { Image( painter = ImagePainter( imageResource(R.drawable.latte_small) ), modifier =

    Modifier.preferredSize(160.dp) ) Text( text = "Latte", style = TextStyle( fontSize = 72.sp, color = Color.White ), modifier = Modifier.padding(end = 30.dp) ) }
  12. Column { Image( painter = ImagePainter( imageResource(R.drawable.latte_small) ), modifier =

    Modifier.preferredSize(160.dp) ) Text( text = "Latte", style = TextStyle( fontSize = 72.sp, color = Color.White ), modifier = Modifier.padding(end = 30.dp) ) }
  13. Stack( modifier = Modifier.preferredSize(400.dp, 160.dp) ) { Image( painter =

    ImagePainter( imageResource(R.drawable.latte_small) ), modifier = Modifier.preferredSize(160.dp) + Modifier.gravity(Alignment.CenterStart) ) Text( text = "Latte", style = TextStyle( fontSize = 72.sp, color = Color.White ), modifier = Modifier.gravity(Alignment.BottomEnd) ) Image( painter = ImagePainter( imageResource(R.drawable.ic_favorite_border) ), modifier = Modifier.padding(20.dp) + Modifier.gravity(Alignment.TopEnd) ) }
  14. ConstraintLayout( modifier = Modifier.preferredSize(400.dp, 160.dp), constraintSet = ConstraintSet { val

    logo = tag("logo") val favourite = tag("favourite") val title = tag("title") favourite.apply { right constrainTo parent.right } title.apply { left constrainTo logo.right centerVertically() } } ) { Image( painter = ImagePainter(imageResource(...)), modifier = Modifier.tag("logo") ) Text( text = "Latte", style = TextStyle(...), modifier = Modifier.tag("title") ) Image( painter = ImagePainter(...), modifier = Modifier.padding(20.dp) + Modifier.tag("favourite") ) }
  15. modifier = Modifier.fillMaxWidth() + Modifier.padding( start = 16.dp, top =

    8.dp, end = 16.dp ) + Modifier.drawOpacity(0.54f) + Modifier.tag(DRINK_INGREDIENTS_TAG) modifier = Modifier.fillMaxWidth() .padding( start = 16.dp, top = 8.dp, end = 16.dp ) .drawOpacity(0.54f) .tag(DRINK_INGREDIENTS_TAG)
  16. @Composable fun CoffeeDrinkListItem( drink: CoffeeDrinkItem ) { ... } data

    class CoffeeDrinkItem( val id: Long, val name: String, val imageUrl: Int, val ingredients: String, var isFavourite: MutableState<Boolean> )
  17. @Composable fun CoffeeDrinkListItem( drink: CoffeeDrinkItem ) { Row { Box(

    modifier = Modifier.preferredSize(72.dp) ) Box( modifier = Modifier.weight(1f) + Modifier.preferredHeight(72.dp) ) Box( modifier = Modifier.preferredSize( width = 40.dp, height = 72.dp ) ) } }
  18. @Composable fun CoffeeDrinkListItem( drink: CoffeeDrinkItem ) { Row { Surface(

    modifier = Modifier.preferredSize(72.dp) + Modifier.padding(16.dp), shape = CircleShape, color = Color(0xFFFAFAFA) ) { Image( painter = ImagePainter( imageResource(drink.imageUrl) ), modifier = Modifier.fillMaxSize() ) } ... } }
  19. @Composable fun CoffeeDrinkListItem( drink: CoffeeDrinkItem ) { Row { ...

    Box( modifier = Modifier.weight(1f) ) { Column { Text( text = drink.title, modifier = Modifier.padding( top = 8.dp, end = 8.dp ), style = TextStyle(fontSize = 24.sp), maxLines = 1 ) Text( text = drink.ingredients, modifier = Modifier.drawOpacity(0.54f), maxLines = 1, overflow = TextOverflow.Ellipsis ) } } } }
  20. @Composable fun CoffeeDrinkListItem( drink: CoffeeDrinkItem ) { Row { ...

    Toggleable( value = coffeeDrink.isFavourite.value, onValueChange = { drink.value.isFavourite.value = !drink.isFavourite.value }, modifier = Modifier.ripple(radius = 24.dp) ) { Box( modifier = Modifier.preferredSize(48.dp) ) { val iconId = if (drink.isFavourite.value) { R.drawable.ic_favourite } else { R.drawable.ic_non_favourite } Image(...) } } } }
  21. @Composable fun CoffeeDrinkListItem( drink: CoffeeDrinkItem ) { Row { Surface(

    modifier = Modifier.preferredSize(72.dp) + Modifier.padding(16.dp), shape = CircleShape, color = Color(0xFFFAFAFA) ) { Image( painter = ImagePainter(imageResource(drink.imageUrl)), modifier = Modifier.fillMaxSize() ) } Box( modifier = Modifier.weight(1f) ) { Column { Text( text = drink.title, modifier = Modifier.padding(top = 8.dp, end = 8.dp), style = TextStyle(fontSize = 24.sp), maxLines = 1 ) Text( text = drinkingredients, modifier = Modifier.drawOpacity(0.54f), maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } Toggleable( value = drink.isFavourite.value, onValueChange = { drink.isFavourite.value = !drink.isFavourite.value }, modifier = Modifier.ripple(radius = 24.dp) ) { Box( modifier = Modifier.preferredSize(48.dp) ) { val iconId = ... Image(...) } } } }
  22. @Composable fun CoffeeDrinkListItem( drink:CoffeeDrinkItem ) { Row { Logo(drink.imageUrl) AdditionalInformation

    { Title(drink.title) Ingredients(drink.ingredients) } Favourite(drink) } }
  23. AdapterList( data = coffeeDrinks ) { coffeeDrink -> Box( modifier

    = Modifier.ripple(bounded = true) + Modifier.clickable(onClick = {}) ) { CoffeeDrinkListItem(coffeeDrink) } }
  24. @Composable private fun showDrinks() { AdapterList( data = coffeeDrinks )

    { coffeeDrink -> Box( modifier = Modifier.ripple(bounded = true) + Modifier.clickable(onClick = {}) ) { Column { CoffeeDrinkListItem(coffeeDrink) CoffeeDrinkDivider() } } } } @Composable private fun CoffeeDrinkDivider() { Divider( modifier = Modifier.padding(start = 72.dp) + Modifier.drawOpacity(0.12f), color = Color.Black ) }
  25. @Composable fun CoffeeDrinkAppBar() { TopAppBar( title = { Text( text

    = "Coffee Drinks", style = TextStyle(color = Color.White, ...) ) }, backgroundColor = Color(0xFF562A1F), actions = { IconButton( onClick = { } ) { Icon( painter = ImagePainter( imageResource( R.drawable.ic_extended_list_white ) ), tint = Color.White ) } } ) }
  26. @Composable fun CoffeeDrinkAppBar(cardType: CardType) { TopAppBar( title = { ...

    }, actions = { IconButton( onClick = { ... } ) { Icon( painter = ImagePainter( imageResource(id = if (cardType.isDetailedItem.value) R.drawable.ic_list_white else R.drawable.ic_extended_list_white ) ), tint = Color.White ) } } ) } data class CardType( var isDetailedItem: MutableState<Boolean> = mutableStateOf(false) )
  27. private const val HEADER_TAG = "header" private const val LOGO_TAG

    = "logo" @Composable fun CoffeeDrinkDetailsScreen( coffeeDrinks: MutableState<CoffeeDrinkItem> ) { ConstraintLayout( constraintSet = ConstraintSet { val header = tag(HEADER_TAG) val logo= tag(LOGO_TAG) ... logo.apply { top constrainTo parent.top bottom constrainTo header.bottom left constrainTo parent.left right constrainTo parent.right } } ) { ... Image( painter = ImagePainter( imageResource(id = R.drawable.americano_small) ), modifier = Modifier.tag(LOGO_TAG) ) ... } }
  28. private const val HEADER_TAG = "header" private const val LOGO_TAG

    = "logo" @Composable fun CoffeeDrinkDetailsScreen( coffeeDrinks: MutableState<CoffeeDrinkItem> ) { ConstraintLayout( constraintSet = ConstraintSet { val header = tag(HEADER_TAG) val logo = tag(LOGO_TAG) ... logo.apply { top constrainTo parent.top bottom constrainTo header.bottom left constrainTo parent.left right constrainTo parent.right } } ) { ... Image( painter = ImagePainter( imageResource(id = R.drawable.americano_small) ), modifier = Modifier.tag(LOGO_TAG) ) ... } }
  29. private const val SURFACE_TAG = "surface" private const val HEADER_TAG

    = "header" @Composable fun CoffeeDrinkDetailsScreen( coffeeDrinks: MutableState<CoffeeDrinkItem> ) { ConstraintLayout( constraintSet = ConstraintSet { val header = tag(HEADER_TAG) val logo = tag(LOGO_TAG) ... logo.apply { top constrainTo parent.top bottom constrainTo header.bottom left constrainTo parent.left right constrainTo parent.right } } ) { ... Image( painter = ImagePainter( imageResource(id = R.drawable.americano_small) ), modifier = Modifier.tag(LOGO_TAG) ) ... } }
  30. data class Substring( val value: String, val startPosition: Int )

    { val endPosition = startPosition + value.length } val nameSubstring = getSubstring(coffeeDrink.name, coffeeDrink.description) val substrings = getSubstrings(dictionary, coffeeDrink.description) val description = AnnotatedString { append(coffeeDrink.description) addStyle( style = SpanStyle( color = Color(0xFF562a1f), fontWeight = FontWeight.Bold, fontSize = 18.sp ), start = nameSubstring.startPosition, end = nameSubstring.endPosition ) substrings.forEach { addStyle( style = SpanStyle( fontStyle = FontStyle.Italic, textDecoration = TextDecoration.Underline ), start = it.startPosition, end = it.endPosition ) } }
  31. data class Substring( val value: String, val startPosition: Int )

    { val endPosition = startPosition + value.length } val nameSubstring = getSubstring(coffeeDrink.name, coffeeDrink.description) val substrings = getSubstrings(dictionary, coffeeDrink.description) val description = AnnotatedString { append(coffeeDrink.description) addStyle( style = SpanStyle( color = Color(0xFF562a1f), fontWeight = FontWeight.Bold, fontSize = 18.sp ), start = nameSubstring.startPosition, end = nameSubstring.endPosition ) substrings.forEach { addStyle( style = SpanStyle( fontStyle = FontStyle.Italic, textDecoration = TextDecoration.Underline ), start = it.startPosition, end = it.endPosition ) } }
  32. data class Substring( val value: String, val startPosition: Int )

    { val endPosition = startPosition + value.length } val nameSubstring = getSubstring(coffeeDrink.name, coffeeDrink.description) val substrings = getSubstrings(dictionary, coffeeDrink.description) val description = AnnotatedString { append(coffeeDrink.description) addStyle( style = SpanStyle( color = Color(0xFF562a1f), fontWeight = FontWeight.Bold, fontSize = 18.sp ), start = nameSubstring.startPosition, end = nameSubstring.endPosition ) substrings.forEach { addStyle( style = SpanStyle( fontStyle = FontStyle.Italic, textDecoration = TextDecoration.Underline ), start = it.startPosition, end = it.endPosition ) } }
  33. Activity/Fragment State sealed class RouterDestination { object CoffeeDrinks : RouterDestination()

    data class CoffeeDrinkDetails( val coffeeDrinkId: Long ) : RouterDestination() object OrderCoffeeDrinks : RouterDestination() } data class RouteState( var currentState: MutableState<RouterDestination> ) class Router(var state: RouteState) { fun navigateTo(destination: RouterDestination) { state.currentState.value = destination } } @Composable fun AppContent() { val routeState = state { RouterDestination.CoffeeDrinks } val router = Router(RouteState(routeState)) MaterialTheme(...) { Crossfade(router.state.currentState.value) { screen -> when (screen) { is StateRouterDestination.CoffeeDrinks -> CoffeeDrinksScreen(router, ...) ... } } } }
  34. Activity/Fragment State sealed class RouterDestination { object CoffeeDrinks : RouterDestination()

    data class CoffeeDrinkDetails( val coffeeDrinkId: Long ) : RouterDestination() object OrderCoffeeDrinks : RouterDestination() } data class RouteState( var currentState: MutableState<RouterDestination> ) class Router(var state: RouteState) { fun navigateTo(destination: RouterDestination) { state.currentState.value = destination } } @Composable fun AppContent() { val routeState = state { RouterDestination.CoffeeDrinks } val router = Router(RouteState(routeState)) MaterialTheme(...) { Crossfade(router.state.currentState.value) { screen -> when (screen) { is StateRouterDestination.CoffeeDrinks -> CoffeeDrinksScreen(router, ...) ... } } } }
  35. Activity/Fragment State sealed class RouterDestination { object CoffeeDrinks : RouterDestination()

    data class CoffeeDrinkDetails( val coffeeDrinkId: Long ) : RouterDestination() object OrderCoffeeDrinks : RouterDestination() } data class RouteState( var currentState: MutableState<RouterDestination> ) class Router(var state: RouteState) { fun navigateTo(destination: RouterDestination) { state.currentState.value = destination } } @Composable fun AppContent() { val routeState = state { RouterDestination.CoffeeDrinks } val router = Router(RouteState(routeState)) MaterialTheme(...) { Crossfade(router.state.currentState.value) { screen -> when (screen) { is StateRouterDestination.CoffeeDrinks -> CoffeeDrinksScreen(router, ...) ... } } } }
  36. val lightThemeColors = lightColorPalette( primary = Color(0xFF663e34), primaryVariant = Color(0xFF562a1f),

    secondary = Color(0xFF855446), secondaryVariant = Color(0xFFb68171), background = Color.White, surface = Color.White, error = Color(0xFFB00020), onPrimary = Color.White, onSecondary = Color.White, onBackground = Color.Black, onSurface = Color.Black, onError = Color.White ) https://material.io/design/color/applying-color-to-ui.html
  37. @Composable fun AppContent() { val colorPalette = if (isSystemInDarkTheme()) {

    darkThemeColors } else { lightThemeColors } MaterialTheme( colors = colorPalette ) { ... } } @Preview @Composable fun previewAppContent() { MaterialTheme(colors = lightThemeColors) { ... } } Text( style = MaterialTheme.typography.h4.copy( color = MaterialTheme.colors.onSurface ), ... )
  38. private val appFontFamily = fontFamily( listOf( ResourceFont( resId = R.font.roboto_black,

    weight = FontWeight.W900, style = FontStyle.Normal ), ResourceFont( resId = R.font.roboto_medium_italic, weight = FontWeight.W500, style = FontStyle.Italic ), ResourceFont( resId = R.font.roboto_thin, weight = FontWeight.W100, style = FontStyle.Normal ), ... ) ) private val defaultTypography = Typography() val appTypography = Typography( h1 = defaultTypography.h1.copy( fontFamily = appFontFamily ), ... body1 = defaultTypography.body1.copy( fontFamily = appFontFamily ), body2 = defaultTypography.body2.copy( fontFamily = appFontFamily ), button = defaultTypography.button.copy( fontFamily = appFontFamily ), ... )
  39. @Composable fun AppContent() { MaterialTheme( colors = lightThemeColors, typography =

    appTypography ) { ... Text( ... style = MaterialTheme.typography.h5 ) Text( ... style = MaterialTheme.typography.h5.copy( color = MaterialTheme.colors.onSurface ) ) } }
  40. fun ComposeTestRule.launchCoffeeDrinksScreen( router: Router, repository: CoffeeDrinkRepository, mapper: CoffeeDrinkItemMapper ) {

    setContent { CoffeeDrinksScreen( router, repository, mapper ) } } @RunWith(JUnit4::class) class CoffeeDrinksScreenTest { @get:Rule val composeTestRule = createComposeRule() private val repository = ... private val mapper = ... @Before fun setUp() { composeTestRule.launchCoffeeDrinksScreen( repository, mapper ) } @Test fun shouldLaunchApp() { findByText("Coffee Drinks") .assertIsDisplayed() } @Test fun shouldLoadAmericano() { findBySubstring("Americano") .assertIsDisplayed() } }
  41. fun ComposeTestRule.launchCoffeeDrinksScreen( router: Router, repository: CoffeeDrinkRepository, mapper: CoffeeDrinkItemMapper ) {

    setContent { CoffeeDrinksScreen( router, repository, mapper ) } } @RunWith(JUnit4::class) class CoffeeDrinksScreenTest { @get:Rule val composeTestRule = createComposeRule() private val repository = ... private val mapper = ... @Before fun setUp() { composeTestRule.launchCoffeeDrinksScreen( router, repository, mapper ) } @Test fun shouldLaunchApp() { findByText("Coffee Drinks") .assertIsDisplayed() } @Test fun shouldLoadAmericano() { findBySubstring("Americano") .assertIsDisplayed() } }
  42. Interoperability Right To Left Resources Text( stringResource(R.string.name) ) Icon( painter

    = ImagePainter( imageResource( R.drawable.ic_arrow_back ) ) )
  43. Resources Right To Left Interoperability @Composable @GenerateView fun CustomButton( title:

    String, icon: Int, ) { ... } <CustomButton android:id="@+id/button" ... app:title="@string/app_name" app:icon="@drawable/button_icon" /> https://www.youtube.com/watch?v=VsStyq4Lzxo