Upgrade to Pro — share decks privately, control downloads, hide ads and more …

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

Avatar for Alex Zhukovich

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