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

2b0404a5db1a74f01bf3bf94d142e28c?s=128

Alex Zhukovich

April 20, 2020
Tweet

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