Slide 1

Slide 1 text

Prototyping With Jetpack Compose Ben Oberkfell Droidcon NYC 2019

Slide 2

Slide 2 text

About Me • Android Engineer at the New York Times since 2017 • Having fun on the NYT Games Team • @benlikestocode

Slide 3

Slide 3 text

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

Slide 4

Slide 4 text

My Journey into Compose

Slide 5

Slide 5 text

No content

Slide 6

Slide 6 text

The Mini Crossword 5x5 grid Easy Solve Free to Play Fewer/no “special” puzzles

Slide 7

Slide 7 text

No content

Slide 8

Slide 8 text

No content

Slide 9

Slide 9 text

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) } }

Slide 10

Slide 10 text

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) } }

Slide 11

Slide 11 text

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) } }

Slide 12

Slide 12 text

Black & White Squares @Composable fun SquareDemo() { Surface(color = Color.Gray) { Column { Row { BlackSquare(200.dp) } Row { WhiteSquare(200.dp) } } } }

Slide 13

Slide 13 text

Cursors

Slide 14

Slide 14 text

Cursors Cursor Active Clue Unselected

Slide 15

Slide 15 text

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 }

Slide 16

Slide 16 text

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) } }

Slide 17

Slide 17 text

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) } }

Slide 18

Slide 18 text

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) } } } }

Slide 19

Slide 19 text

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) } } } }

Slide 20

Slide 20 text

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)) } } } }

Slide 21

Slide 21 text

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)) } } } }

Slide 22

Slide 22 text

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)) } } } }

Slide 23

Slide 23 text

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)) } } } }

Slide 24

Slide 24 text

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)) } } } }

Slide 25

Slide 25 text

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)) } } } }

Slide 26

Slide 26 text

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)) } } } }

Slide 27

Slide 27 text

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") } } } }

Slide 28

Slide 28 text

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)) } } } }

Slide 29

Slide 29 text

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)) } } } }

Slide 30

Slide 30 text

Adding a Number if (cellNumber != null) { aligned(Alignment.TopLeft) { Padding(3.dp) { Text(cellNumber, style = TextStyle(fontSize = numberSize)) } } }

Slide 31

Slide 31 text

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") } } } }

Slide 32

Slide 32 text

BZZZT, wrong

Slide 33

Slide 33 text

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 ) } }

Slide 34

Slide 34 text

No content

Slide 35

Slide 35 text

No content

Slide 36

Slide 36 text

withDensity paint.strokeWidth = +withDensity { 5.dp.toPx().value }

Slide 37

Slide 37 text

BZZZT, wrong if (strikeOut) { aligned(Alignment.Center) { Container(width = dimension, height = dimension) { StrikeOut() } } } In our stack:

Slide 38

Slide 38 text

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) } } } }

Slide 39

Slide 39 text

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) } } }

Slide 40

Slide 40 text

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) } } }

Slide 41

Slide 41 text

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) } } }

Slide 42

Slide 42 text

Model-Driven LetterSquare( dimension = 150.dp, square = Square.forLetter(answer = "A") )

Slide 43

Slide 43 text

Build a Puzzle @Model class Board(val edgeCount: Int, val squares: List) 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) }

Slide 44

Slide 44 text

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) } } } } } }

Slide 45

Slide 45 text

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! } }

Slide 46

Slide 46 text

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) } } } }

Slide 47

Slide 47 text

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) } } } }

Slide 48

Slide 48 text

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) } } } }

Slide 49

Slide 49 text

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) } } } }

Slide 50

Slide 50 text

Render the Board

Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

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

Slide 55

Slide 55 text

Render the Board

Slide 56

Slide 56 text

Clues class Clue(val direction: Direction, val cells: List, 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”), … )

Slide 57

Slide 57 text

Cursors and Clues class Board(val edgeCount: Int, val squares: List, val clues: List) { 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 } }

Slide 58

Slide 58 text

Wiring Up The Click Event Clickable(onClick = {onSquareClicked(model)}) { Stack { // … stack for the square elements } } In LetterSquare: fun onSquareClicked(square: Square) { theBoard.selectSquare(square) }

Slide 59

Slide 59 text

Filling the Board @Model class Board(val edgeCount: Int, val squares: List, val clues: List) { //… 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) }

Slide 60

Slide 60 text

Filling the Board

Slide 61

Slide 61 text

Checking Your Work if (square.checked && square.answer != square.userAnswer) { aligned(Alignment.Center) { Container(width = dimension, height = dimension) { StrikeOut() } } }

Slide 62

Slide 62 text

Checking Your Work Button(text = "Check My Work", onClick = { board.check() }) @Model class Board(val edgeCount: Int, val squares: List, val clues: List) { fun enterLetter(letter: String) { squares[selectedCellIndex].userAnswer = letter.toUpperCase(Locale.US) squares[selectedCellIndex].checked = false advanceCursorToNextSquare() } fun check() { squares.forEach { it.checked = true } } }

Slide 63

Slide 63 text

Checking Your Work

Slide 64

Slide 64 text

We Did It!

Slide 65

Slide 65 text

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!

Slide 66

Slide 66 text

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/

Slide 67

Slide 67 text

THANK YOU! P.S. We’re hiring; stop by our table and say hi.