$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. Let’s build an Android UI with Jetpack Compose @ Alex

    Zhukovich
  2. None
  3. View framework

  4. None
  5. setFirstBaselineToTopHeight API level 28 setTextLocale API level 17 onRtlPropertiesChanged API

    level 17 setPaddingRelative API level 16
  6. View 29.000+ LOC TextView 13.000+ LOC Button 180 LOC CompoundButton

    650 LOC Switch 1500+LOC CheckBox 76 LOC
  7. 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(); } }
  8. Jetpack Compose

  9. Unbundled from Android Interoperability with View framework Android Studio 4.1+

  10. UI Runtime Plugin

  11. @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) } } } }
  12. @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") } }
  13. Calendar { repeat(4) { CalendarWeek { repeat(7) { CalendarItem( month

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

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

    = "APR", date = "20", day = "Mon" ) } } } }
  16. 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() { ... } }
  17. 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 = "-") } } }
  18. @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 = "-") } } }
  19. 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
  20. Layouts

  21. Row Column ConstraintLayout Stack

  22. 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) ) }
  23. 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) ) }
  24. 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) ) }
  25. 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") ) }
  26. 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)
  27. https://github.com/AlexZhukovich/CoffeeDrinksWithJetpackCompose

  28. None
  29. @Composable fun CoffeeDrinkListItem( drink: CoffeeDrinkItem ) { ... } data

    class CoffeeDrinkItem( val id: Long, val name: String, val imageUrl: Int, val ingredients: String, var isFavourite: MutableState<Boolean> )
  30. @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 ) ) } }
  31. @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() ) } ... } }
  32. @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 ) } } } }
  33. @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(...) } } } }
  34. @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(...) } } } }
  35. @Composable fun CoffeeDrinkListItem( drink:CoffeeDrinkItem ) { Row { Logo(drink.imageUrl) AdditionalInformation

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

    = Modifier.ripple(bounded = true) + Modifier.clickable(onClick = {}) ) { CoffeeDrinkListItem(coffeeDrink) } }
  37. @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 ) }
  38. @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 ) } } ) }
  39. @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) )
  40. 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) ) ... } }
  41. 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) ) ... } }
  42. 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) ) ... } }
  43. 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 ) } }
  44. 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 ) } }
  45. 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 ) } }
  46. Routing

  47. Activity/Fragment State Clickable(onClick = { startActivity( Intent( this@MainActivity, TestActivity::class.java )

    ) }
  48. 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, ...) ... } } } }
  49. 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, ...) ... } } } }
  50. 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, ...) ... } } } }
  51. Theme

  52. None
  53. 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
  54. @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 ), ... )
  55. Typography

  56. 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 ), ... )
  57. @Composable fun AppContent() { MaterialTheme( colors = lightThemeColors, typography =

    appTypography ) { ... Text( ... style = MaterialTheme.typography.h5 ) Text( ... style = MaterialTheme.typography.h5.copy( color = MaterialTheme.colors.onSurface ) ) } }
  58. Testing

  59. 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() } }
  60. 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() } }
  61. Notes

  62. Interoperability Right To Left Resources Text( stringResource(R.string.name) ) Icon( painter

    = ImagePainter( imageResource( R.drawable.ic_arrow_back ) ) )
  63. Resources Right To Left Interoperability

  64. 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
  65. Web Map Animations

  66. Web Map Animations Developer Preview

  67. #ExploreMore Jetpack Compose alexzh.com @AlexZhukovich