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

Prototyping with Jetpack Compose

Prototyping with Jetpack Compose

We set out to prototype a Jetpack Compose user interface for a real-world Android game: The New York Times Crossword. This is a fun challenge, because the game board is a complex view, chock full of stateful elements!

Join us to see how we got started, what we learned, and some sample code.

Ben Oberkfell

August 26, 2019
Tweet

More Decks by Ben Oberkfell

Other Decks in Programming

Transcript

  1. About Me • Android Engineer at the New York Times

    since 2017 • Having fun on the NYT Games Team • @benlikestocode
  2. Caveat Emptor • There’s minimal documentation on Jetpack Compose •

    JavaDoc • Source • Other folks’ blogposts • Here’s my journey through trial and error • Stick around in this room for the next talk to hear more perspectives
  3. Black & White Squares @Composable fun DrawSquare(color: Color) { val

    paint = Paint() paint.color = color Draw { canvas, parentSize -> canvas.drawRect(parentSize.toRect(), paint) } } @Composable fun BlackSquare(dimension: Dp) { Container(width = dimension, height = dimension) { DrawSquare(Color.Black) } } @Composable fun WhiteSquare(dimension: Dp) { Container(width = dimension, height = dimension) { DrawSquare(Color.White) } }
  4. Black & White Squares @Composable fun DrawSquare(color: Color) { val

    paint = Paint() paint.color = color Draw { canvas, parentSize -> canvas.drawRect(parentSize.toRect(), paint) } } @Composable fun BlackSquare(dimension: Dp) { Container(width = dimension, height = dimension) { DrawSquare(Color.Black) } } @Composable fun WhiteSquare(dimension: Dp) { Container(width = dimension, height = dimension) { DrawSquare(Color.White) } }
  5. Black & White Squares @Composable fun DrawSquare(color: Color) { val

    paint = Paint() paint.color = color Draw { canvas, parentSize -> canvas.drawRect(parentSize.toRect(), paint) } } @Composable fun BlackSquare(dimension: Dp) { Container(width = dimension, height = dimension) { DrawSquare(Color.Black) } } @Composable fun WhiteSquare(dimension: Dp) { Container(width = dimension, height = dimension) { DrawSquare(Color.White) } }
  6. Black & White Squares @Composable fun SquareDemo() { Surface(color =

    Color.Gray) { Column { Row { BlackSquare(200.dp) } Row { WhiteSquare(200.dp) } } } }
  7. Cursors enum class CellBackground constructor(val color: Color) { NONE(Color.White), CURSOR(Color(red

    = 0xFF, green = 0xDA, blue = 0x00)), ACTIVE_CLUE(Color(red = 0xA7, green = 0xD8, blue = 0xFF)), BLACK_SQUARE(Color.Black) } enum class SelectionMode { NONE, CURSOR, ACTIVE_CLUE }
  8. Cursors @Composable fun WhiteSquare(dimension: Dp, selectionMode: SelectionMode) { val background

    = when (selectionMode) { SelectionMode.NONE -> CellBackground.NONE SelectionMode.CURSOR -> CellBackground.CURSOR SelectionMode.ACTIVE_CLUE -> CellBackground.ACTIVE_CLUE } Container(width = dimension, height = dimension) { DrawSquare(background.color) } }
  9. Cursors @Composable fun WhiteSquare(dimension: Dp, selectionMode: SelectionMode) { val background

    = when (selectionMode) { SelectionMode.NONE -> CellBackground.NONE SelectionMode.CURSOR -> CellBackground.CURSOR SelectionMode.ACTIVE_CLUE -> CellBackground.ACTIVE_CLUE } Container(width = dimension, height = dimension) { DrawSquare(background.color) } }
  10. Cursors @Composable fun SquareDemo() { Surface(color = Color.Gray) { Column

    { Row { BlackSquare(150.dp) } Row { WhiteSquare( 150.dp, selectionMode = SelectionMode.CURSOR) } Row { WhiteSquare( 150.dp, selectionMode = SelectionMode.ACTIVE_CLUE) } Row { WhiteSquare( 150.dp, selectionMode = SelectionMode.NONE) } } } }
  11. Cursors @Composable fun SquareDemo() { Surface(color = Color.Gray) { Column

    { Row { BlackSquare(150.dp) } Row { WhiteSquare( 150.dp, selectionMode = SelectionMode.CURSOR) } Row { WhiteSquare( 150.dp, selectionMode = SelectionMode.ACTIVE_CLUE) } Row { WhiteSquare( 150.dp, selectionMode = SelectionMode.NONE) } } } }
  12. Scaled Text @Composable fun WhiteSquare(dimension: Dp, selectionMode: SelectionMode, text: String?

    = null) { val background = when (selectionMode) { SelectionMode.NONE -> CellBackground.NONE SelectionMode.CURSOR -> CellBackground.CURSOR SelectionMode.ACTIVE_CLUE -> CellBackground.ACTIVE_CLUE } val textSize = Sp(dimension.value * 0.6f) Stack { aligned(Alignment.Center) { Container(width = dimension, height = dimension) { DrawSquare(background.color) } } if (text != null) { aligned(Alignment.Center) { Text(text, style = TextStyle(fontSize = textSize)) } } } }
  13. Scaled Text @Composable fun WhiteSquare(dimension: Dp, selectionMode: SelectionMode, text: String?

    = null) { val background = when (selectionMode) { SelectionMode.NONE -> CellBackground.NONE SelectionMode.CURSOR -> CellBackground.CURSOR SelectionMode.ACTIVE_CLUE -> CellBackground.ACTIVE_CLUE } val textSize = Sp(dimension.value * 0.6f) Stack { aligned(Alignment.Center) { Container(width = dimension, height = dimension) { DrawSquare(background.color) } } if (text != null) { aligned(Alignment.Center) { Text(text, style = TextStyle(fontSize = textSize)) } } } }
  14. Scaled Text @Composable fun WhiteSquare(dimension: Dp, selectionMode: SelectionMode, text: String?

    = null) { val background = when (selectionMode) { SelectionMode.NONE -> CellBackground.NONE SelectionMode.CURSOR -> CellBackground.CURSOR SelectionMode.ACTIVE_CLUE -> CellBackground.ACTIVE_CLUE } val textSize = Sp(dimension.value * 0.6f) Stack { aligned(Alignment.Center) { Container(width = dimension, height = dimension) { DrawSquare(background.color) } } if (text != null) { aligned(Alignment.Center) { Text(text, style = TextStyle(fontSize = textSize)) } } } }
  15. Scaled Text @Composable fun WhiteSquare(dimension: Dp, selectionMode: SelectionMode, text: String?

    = null) { val background = when (selectionMode) { SelectionMode.NONE -> CellBackground.NONE SelectionMode.CURSOR -> CellBackground.CURSOR SelectionMode.ACTIVE_CLUE -> CellBackground.ACTIVE_CLUE } val textSize = Sp(dimension.value * 0.6f) Stack { aligned(Alignment.Center) { Container(width = dimension, height = dimension) { DrawSquare(background.color) } } if (text != null) { aligned(Alignment.Center) { Text(text, style = TextStyle(fontSize = textSize)) } } } }
  16. Scaled Text @Composable fun WhiteSquare(dimension: Dp, selectionMode: SelectionMode, text: String?

    = null) { val background = when (selectionMode) { SelectionMode.NONE -> CellBackground.NONE SelectionMode.CURSOR -> CellBackground.CURSOR SelectionMode.ACTIVE_CLUE -> CellBackground.ACTIVE_CLUE } val textSize = Sp(dimension.value * 0.6f) Stack { aligned(Alignment.Center) { Container(width = dimension, height = dimension) { DrawSquare(background.color) } } if (text != null) { aligned(Alignment.Center) { Text(text, style = TextStyle(fontSize = textSize)) } } } }
  17. Scaled Text @Composable fun WhiteSquare(dimension: Dp, selectionMode: SelectionMode, text: String?

    = null) { val background = when (selectionMode) { SelectionMode.NONE -> CellBackground.NONE SelectionMode.CURSOR -> CellBackground.CURSOR SelectionMode.ACTIVE_CLUE -> CellBackground.ACTIVE_CLUE } val textSize = Sp(dimension.value * 0.6f) Stack { aligned(Alignment.Center) { Container(width = dimension, height = dimension) { DrawSquare(background.color) } } if (text != null) { aligned(Alignment.Center) { Text(text, style = TextStyle(fontSize = textSize)) } } } }
  18. Scaled Text @Composable fun WhiteSquare(dimension: Dp, selectionMode: SelectionMode, text: String?

    = null) { val background = when (selectionMode) { SelectionMode.NONE -> CellBackground.NONE SelectionMode.CURSOR -> CellBackground.CURSOR SelectionMode.ACTIVE_CLUE -> CellBackground.ACTIVE_CLUE } val textSize = Sp(dimension.value * 0.6f) Stack { aligned(Alignment.Center) { Container(width = dimension, height = dimension) { DrawSquare(background.color) } } if (text != null) { aligned(Alignment.Center) { Text(text, style = TextStyle(fontSize = textSize)) } } } }
  19. Scaled Text @Composable fun SquareDemo() { Surface(color = Color.Gray) {

    Column { Row { WhiteSquare( 150.dp, selectionMode = SelectionMode.NONE, text = "A") } Row { WhiteSquare( 200.dp, selectionMode = SelectionMode.NONE, text = "B") } } } }
  20. Adding a Number @Composable fun WhiteSquare(dimension: Dp, selectionMode: SelectionMode, text:

    String? = null, cellNumber: String? = null) { val background = when (selectionMode) { SelectionMode.NONE -> CellBackground.NONE SelectionMode.CURSOR -> CellBackground.CURSOR SelectionMode.ACTIVE_CLUE -> CellBackground.ACTIVE_CLUE } val textSize = Sp(dimension.value * 0.6f) val numberSize = Sp(dimension.value * 0.15f) Stack { aligned(Alignment.Center) { Container(width = dimension, height = dimension) { DrawSquare(background.color) } } if (cellNumber != null) { aligned(Alignment.TopLeft) { Padding(3.dp) { Text(cellNumber, style = TextStyle(fontSize = numberSize)) } } } if (text != null) { aligned(Alignment.Center) { Text(text, style = TextStyle(fontSize = textSize)) } } } }
  21. Adding a Number @Composable fun WhiteSquare(dimension: Dp, selectionMode: SelectionMode, text:

    String? = null, cellNumber: String? = null) { val background = when (selectionMode) { SelectionMode.NONE -> CellBackground.NONE SelectionMode.CURSOR -> CellBackground.CURSOR SelectionMode.ACTIVE_CLUE -> CellBackground.ACTIVE_CLUE } val textSize = Sp(dimension.value * 0.6f) val numberSize = Sp(dimension.value * 0.15f) Stack { aligned(Alignment.Center) { Container(width = dimension, height = dimension) { DrawSquare(background.color) } } if (cellNumber != null) { aligned(Alignment.TopLeft) { Padding(3.dp) { Text(cellNumber, style = TextStyle(fontSize = numberSize)) } } } if (text != null) { aligned(Alignment.Center) { Text(text, style = TextStyle(fontSize = textSize)) } } } }
  22. Adding a Number if (cellNumber != null) { aligned(Alignment.TopLeft) {

    Padding(3.dp) { Text(cellNumber, style = TextStyle(fontSize = numberSize)) } } }
  23. Adding a Number @Composable fun SquareDemo() { Surface(color = Color.Gray)

    { Column { Row { WhiteSquare( 150.dp, selectionMode = SelectionMode.NONE, text = "A") } Row { WhiteSquare( 200.dp, selectionMode = SelectionMode.NONE, text = "B", cellNumber = "5") } } } }
  24. BZZZT, wrong @Composable fun StrikeOut() { val paint = Paint()

    paint.color = Color.Red paint.strokeWidth = +withDensity { 5.dp.toPx().value } Draw { canvas, parentSize -> canvas.drawLine( Offset(parentSize.width.value, 0f), Offset(0f, parentSize.height.value), paint ) } }
  25. BZZZT, wrong @Composable fun SquareDemo() { Surface(color = Color.Gray) {

    Column { Row { WhiteSquare( 150.dp, selectionMode = SelectionMode.NONE, text = "A") } Row { WhiteSquare( 200.dp, selectionMode = SelectionMode.NONE, text = "B", cellNumber = "5", strikeOut = true) } } } }
  26. Adapting to a Model enum class SquareType { BLACK, LETTER

    } @Model class Square private constructor( val squareType: SquareType, val answer: String? = null, val cellNumber: String? = null, var userAnswer: String? = null, var checked: Boolean = false, var selectionMode: SelectionMode = SelectionMode.NONE) { companion object { fun forLetter(answer: String, cellNumber: String? = null): Square { return Square( squareType = SquareType.LETTER, answer = answer, cellNumber = cellNumber, selectionMode = SelectionMode.NONE ) } fun forBlack() : Square { return Square(squareType = SquareType.BLACK) } } }
  27. Adapting to a Model enum class SquareType { BLACK, LETTER

    } @Model class Square private constructor( val squareType: SquareType, val answer: String? = null, val cellNumber: String? = null, var userAnswer: String? = null, var checked: Boolean = false, var selectionMode: SelectionMode = SelectionMode.NONE) { companion object { fun forLetter(answer: String, cellNumber: String? = null): Square { return Square( squareType = SquareType.LETTER, answer = answer, cellNumber = cellNumber, selectionMode = SelectionMode.NONE ) } fun forBlack() : Square { return Square(squareType = SquareType.BLACK) } } }
  28. Adapting to a Model enum class SquareType { BLACK, LETTER

    } @Model class Square private constructor( val squareType: SquareType, val answer: String? = null, val cellNumber: String? = null, var userAnswer: String? = null, var checked: Boolean = false, var selectionMode: SelectionMode = SelectionMode.NONE) { companion object { fun forLetter(answer: String, cellNumber: String? = null): Square { return Square( squareType = SquareType.LETTER, answer = answer, cellNumber = cellNumber, selectionMode = SelectionMode.NONE ) } fun forBlack() : Square { return Square(squareType = SquareType.BLACK) } } }
  29. Build a Puzzle @Model class Board(val edgeCount: Int, val squares:

    List<Square>) fun board() : Board { val squares = listOf( Square.forBlack(), Square.forLetter(answer = "S", cellNumber = "1"), Square.forLetter(answer = "P", cellNumber = "2"), Square.forLetter(answer = "I", cellNumber = "3"), Square.forLetter(answer = "T", cellNumber = "4"), // 20 more of these… ) return Board(squares = squares, edgeCount = 5) }
  30. Render the Board @Composable fun GameTable(board: Board) { WithConstraints {

    constraints -> val containerWidth = +withDensity { constraints.maxWidth.toDp() } // Calculate width of a square val squareWidth = containerWidth/board.edgeCount Table(columnCount = board.edgeCount) { for (rowIndex in 0 until board.edgeCount) { tableRow { columnIndex -> val square = board.squares[(rowIndex * 5) + columnIndex] when(square.squareType) { SquareType.LETTER -> LetterSquare( dimension = squareWidth, model = square) SquareType.BLACK -> BlackSquare(dimension = squareWidth) } } } } } }
  31. Constraints & Density @Composable fun GameTable(board: Board) { WithConstraints {

    constraints -> //constraints are in pixels val containerWidth = +withDensity { constraints.maxWidth.toDp() } // now we have it in dips! } }
  32. Render the Board val squareWidth = containerWidth/board.edgeCount Table(columnCount = board.edgeCount)

    { for (rowIndex in 0 until board.edgeCount) { tableRow { columnIndex -> val square = board.squares[(rowIndex * 5) + columnIndex] when(square.squareType) { SquareType.LETTER -> LetterSquare( dimension = squareWidth, model = square) SquareType.BLACK -> BlackSquare(dimension = squareWidth) } } } }
  33. Render the Board val squareWidth = containerWidth/board.edgeCount Table(columnCount = board.edgeCount)

    { for (rowIndex in 0 until board.edgeCount) { tableRow { columnIndex -> val square = board.squares[(rowIndex * 5) + columnIndex] when(square.squareType) { SquareType.LETTER -> LetterSquare( dimension = squareWidth, model = square) SquareType.BLACK -> BlackSquare(dimension = squareWidth) } } } }
  34. Render the Board val squareWidth = containerWidth/board.edgeCount Table(columnCount = board.edgeCount)

    { for (rowIndex in 0 until board.edgeCount) { tableRow { columnIndex -> val square = board.squares[(rowIndex * 5) + columnIndex] when(square.squareType) { SquareType.LETTER -> LetterSquare( dimension = squareWidth, model = square) SquareType.BLACK -> BlackSquare(dimension = squareWidth) } } } }
  35. Render the Board val squareWidth = containerWidth/board.edgeCount Table(columnCount = board.edgeCount)

    { for (rowIndex in 0 until board.edgeCount) { tableRow { columnIndex -> val square = board.squares[(rowIndex * 5) + columnIndex] when(square.squareType) { SquareType.LETTER -> LetterSquare( dimension = squareWidth, model = square) SquareType.BLACK -> BlackSquare(dimension = squareWidth) } } } }
  36. Render the Board // Calculate width of a square, accounting

    for borders val containerWithoutBorders = containerWidth - Dp(board.edgeCount + 1.0f) val squareWidth = containerWithoutBorders/board.edgeCount val outerBorder = Border(color = Color.Black, width = 1.dp) val innerBorder = Border(color = Color.LightGray, width = 1.dp) Table(columnCount = board.edgeCount) { drawBorders(defaultBorder = outerBorder) { outer() (1 until rowCount).forEach { row -> horizontal(row, 0 until rowCount, border = innerBorder) } (1 until columnCount).forEach { col -> vertical( col, 0 until columnCount, border = innerBorder) } } // rest of table drawing
  37. Render the Board // Calculate width of a square, accounting

    for borders val containerWithoutBorders = containerWidth - Dp(board.edgeCount + 1.0f) val squareWidth = containerWithoutBorders/board.edgeCount val outerBorder = Border(color = Color.Black, width = 1.dp) val innerBorder = Border(color = Color.LightGray, width = 1.dp) Table(columnCount = board.edgeCount) { drawBorders(defaultBorder = outerBorder) { outer() (1 until rowCount).forEach { row -> horizontal(row, 0 until rowCount, border = innerBorder) } (1 until columnCount).forEach { col -> vertical( col, 0 until columnCount, border = innerBorder) } } // rest of table drawing
  38. Render the Board // Calculate width of a square, accounting

    for borders val containerWithoutBorders = containerWidth - Dp(board.edgeCount + 1.0f) val squareWidth = containerWithoutBorders/board.edgeCount val outerBorder = Border(color = Color.Black, width = 1.dp) val innerBorder = Border(color = Color.LightGray, width = 1.dp) Table(columnCount = board.edgeCount) { drawBorders(defaultBorder = outerBorder) { outer() (1 until rowCount).forEach { row -> horizontal(row, 0 until rowCount, border = innerBorder) } (1 until columnCount).forEach { col -> vertical( col, 0 until columnCount, border = innerBorder) } } // rest of table drawing
  39. Render the Board // Calculate width of a square, accounting

    for borders val containerWithoutBorders = containerWidth - Dp(board.edgeCount + 1.0f) val squareWidth = containerWithoutBorders/board.edgeCount val outerBorder = Border(color = Color.Black, width = 1.dp) val innerBorder = Border(color = Color.LightGray, width = 1.dp) Table(columnCount = board.edgeCount) { drawBorders(defaultBorder = outerBorder) { outer() (1 until rowCount).forEach { row -> horizontal(row, 0 until rowCount, border = innerBorder) } (1 until columnCount).forEach { col -> vertical( col, 0 until columnCount, border = innerBorder) } } // rest of table drawing
  40. Clues class Clue(val direction: Direction, val cells: List<Int>, val clueText:

    String) val clues = listOf( Clue(direction = Direction.ACROSS, cells = listOf(1, 2, 3, 4), clueText = "Card Game That Rewards Speed"), Clue(direction = Direction.ACROSS, cells = listOf(6, 7, 8, 9), clueText = "Low-carb, high-fat diet, familiarly"), Clue(direction = Direction.ACROSS, cells = listOf(10, 11, 12, 13, 14), clueText = "Hi-Falutin'"), Clue(direction = Direction.ACROSS, cells = listOf(15, 16, 17, 18), clueText = "Nevada Neighbor”), … )
  41. Cursors and Clues class Board(val edgeCount: Int, val squares: List<Square>,

    val clues: List<Clue>) { private var selectedCellIndex: Int private var selectedDirection: Direction private var selectedClue: Clue fun selectSquare(square: Square) { // if clicking the cursor cell, flip // selected direction // set selection mode for cells in // selected clue // set selection mode for cursor cell } }
  42. Wiring Up The Click Event Clickable(onClick = {onSquareClicked(model)}) { Stack

    { // … stack for the square elements } } In LetterSquare: fun onSquareClicked(square: Square) { theBoard.selectSquare(square) }
  43. Filling the Board @Model class Board(val edgeCount: Int, val squares:

    List<Square>, val clues: List<Clue>) { //… fun enterLetter(letter: String) { squares[selectedCellIndex].userAnswer = letter.toUpperCase(Locale.US) advanceCursorToNextSquare() } //… } override fun onKeyDown(keyCode: Int, keyEvent: KeyEvent): Boolean { if (keyCode in 9..54) { // alphanumeric theBoard.enterLetter(keyEvent.unicodeChar.toChar().toString()) return true } return super.onKeyDown(keyCode, keyEvent) }
  44. Checking Your Work if (square.checked && square.answer != square.userAnswer) {

    aligned(Alignment.Center) { Container(width = dimension, height = dimension) { StrikeOut() } } }
  45. Checking Your Work Button(text = "Check My Work", onClick =

    { board.check() }) @Model class Board(val edgeCount: Int, val squares: List<Square>, val clues: List<Clue>) { fun enterLetter(letter: String) { squares[selectedCellIndex].userAnswer = letter.toUpperCase(Locale.US) squares[selectedCellIndex].checked = false advanceCursorToNextSquare() } fun check() { squares.forEach { it.checked = true } } }
  46. What’d We Build? • We built a Jetpack Compose UI

    around a data model that’s highly similar to the existing data model in the shipping Crossword app • Decomposed the Crossword UI into its most basic components and rebuilt from the “leaves” inward • Had some fun!
  47. What Now? • Watch the Google I/O talk • The

    material-demos project in the Jetpack Compose source is extremely helpful • Follow from the demo to the platform and there’s some degree of useful Javadoc • Compose from First Principles blogpost • http://intelligiblebabble.com/compose-from-first-principles/