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

Creative UIs with Compose: Kotlinconf 2025

Creative UIs with Compose: Kotlinconf 2025

While most of the time following platform conventions makes sense, Compose UI allows us to be more expressive than ever before.

This talk walks through how UIs have become a little stale, and what videos games can show us about creativity in UIs. Specifically, we recreate part of Persona 5's UI to demonstrate just how far we can push Compose UI.

All art and character designs in this presentation are the property of Atlus Co., Ltd. Material is used for reference and educational purposes only. ©ATLUS ©SEGA

Avatar for Chris Horner

Chris Horner

May 21, 2025
Tweet

More Decks by Chris Horner

Other Decks in Technology

Transcript

  1. - A designer I worked with I arrange rectangles. Sometimes,

    if I want to get fancy, I round the corners.
  2. Flat design Easy for a designer to put together Simple

    for a developer to build Low effort to read Renders quickly Responsive Dark mode
  3. Boring 😴 is good Phone calls Customer support Maps /

    navigation Email But… have we gone too far?
  4. Box( m​ odifier = Modifier .fillMaxSize() .background(color = PersonaRed) )

    { I​ mage( painter = painterResource(R.drawable.bg_splatter), c​ ontentDescription = null, contentScale = ContentScale.FillWidth, m​ odifier = M​ odifier.statusBarsPadding() ​)​ } ©ATLUS ©SEGA
  5. Box( m​ odifier = Modifier .fillMaxSize() .background(color = PersonaRed) )

    { I​ mage( painter = painterResource(R.drawable.bg_splatter), c​ ontentDescription = null, contentScale = ContentScale.FillWidth, m​ odifier = M​ odifier.statusBarsPadding() ​)​ Image( painter = painterResource(R.drawable.logo_im), contentDescription = null, modifier = Modifier .height(100.dp) .statusBarsPadding() .offset(x = 8.dp, y = (-4).dp), ) } ©ATLUS ©SEGA
  6. fun outerBox(): Shape = GenericShape { size -> moveTo(31.7, 3.1)

    lineTo(size.width, 0f) lineTo(size.width - 23, size.height) } ©ATLUS ©SEGA
  7. fun outerBox(): Shape = GenericShape { size -> moveTo(31.7, 3.1)

    lineTo(size.width, 0f) lineTo(size.width - 23, size.height) lineTo(0f, size.height - 8) } ©ATLUS ©SEGA
  8. fun outerBox(): Shape = GenericShape { size -> moveTo(31.7, 3.1)

    lineTo(size.width, 0f) lineTo(size.width - 23, size.height) lineTo(0f, size.height - 8) close() } ©ATLUS ©SEGA
  9. fun outerBox(): Shape = GenericShape { size -> moveTo(31.7, 3.1)

    lineTo(size.width, 0f) lineTo(size.width - 23, size.height) lineTo(0f, size.height - 8) close() } ©ATLUS ©SEGA
  10. fun Density.outerBox(): Shape = GenericShape { s -> moveTo(31.7.dp.toPx(), 3.1.dp.toPx())

    lineTo(size.width, 0f) lineTo(size.width - 23.dp.toPx(), size.height) lineTo(0f, size.height - 8.dp.toPx( )​ ) c​ lose() } ©ATLUS ©SEGA
  11. fun Density.outerBox(): Shape = GenericShape { size -> moveTo(31.7.dp.toPx(), 3.1.dp.toPx())

    lineTo(size.width, 0f) lineTo(size.width - 23.dp.toPx(), size.height) lineTo(0f, size.height - 8.dp.toPx()) ​c​ lose( )​ } fun Density.innerBox(): Shape = GenericShape { size -> moveTo(33.dp.toPx(), 7.7.dp.toPx()) lineTo(size.width - 13.dp.toPx(), 3.7.dp.toPx()) lineTo(size.width - 25.7.dp.toPx(), size.height - 4.6.dp.toPx( lineTo(20.4.dp.toPx(), size.height - 12.dp.toPx()) close() } ©ATLUS ©SEGA
  12. Text( text = message.text, style = MaterialTheme.typography.bodyMedium, color = Color.White,

    fontFamily = OptimaNova, modifier = Modifier .drawBehind { val outerBox = outerBox() val innerBox = innerBox() } ) ©ATLUS ©SEGA
  13. Text( text = message.text, style = MaterialTheme.typography.bodyMedium, color = Color.White,

    fontFamily = OptimaNova, modifier = Modifier .drawBehind { val outerBox = outerBox() val innerBox = innerBox() drawShape(outerBox, color = Color.White) drawShape(innerBox, color = Color.Black) } ) ©ATLUS ©SEGA
  14. Text( text = message.text, style = MaterialTheme.typography.bodyMedium, color = Color.White,

    fontFamily = OptimaNova, modifier = Modifier .drawBehind { val outerBox = Outline(outerBox()) val innerBox = Outline(innerBox()) drawOutline(outerBox, color = Color.White) drawOutline(innerBox, color = Color.Black) } ) ©ATLUS ©SEGA
  15. Text( text = message.text, style = MaterialTheme.typography.bodyMedium, color = Color.White,

    fontFamily = OptimaNova, modifier = Modifier .drawBehind { val outerBox = Outline(outerBox()) val innerBox = Outline(innerBox()) drawOutline(outerBox, color = Color.White) drawOutline(innerBox, color = Color.Black) } .padding( ... ) ) ©ATLUS ©SEGA
  16. Text( text = message.text, style = MaterialTheme.typography.bodyMedium, color = Color.White,

    fontFamily = OptimaNova, modifier = Modifier .drawBehind { val outerBox = Outline(outerBox()) val innerBox = Outline(innerBox()) drawOutline(outerBox, color = Color.White) drawOutline(innerBox, color = Color.Black) } .padding( ... ) ) ©ATLUS ©SEGA
  17. Box( modifier = Modifier .drawBehind { drawOutline(avatarBlackBox(), Color.Black) drawOutline(avatarWhiteBox(), Color.White)

    drawOutline(avatarColoredBox(), pink) } ) { Image( painter = painterResource(R.drawable.ann), contentDescription = null, ) } ©ATLUS ©SEGA
  18. Box( modifier = Modifier .drawBehind { drawOutline(avatarBlackBox(), Color.Black) drawOutline(avatarWhiteBox(), Color.White)

    drawOutline(avatarColoredBox(), pink) drawOutline (​ avatarClipBox(), Color.Magenta) } ) { Image( painter = painterResource(R.drawable.ann), contentDescription = null, ) } ©ATLUS ©SEGA
  19. Box( modifier = Modifier .drawBehind { drawOutline(avatarBlackBox(), Color.Black) drawOutline(avatarWhiteBox(), Color.White)

    drawOutline(avatarColoredBox(), pink) } .clip (​ avatarClipBox( )​ ) ) { Image( painter = painterResource(R.drawable.ann), contentDescription = null, ) } ©ATLUS ©SEGA
  20. Layout( content = { // Composables ... } ) {

    measurables, constraints -> }
  21. Layout( content = { // Composables ... } ) {

    measurables, constraints -> val placeables = measurables.map { it.measure(constraints) } }
  22. Layout( content = { // Composables ... } ) {

    measurables, constraints -> val placeables = measurables.map { it.measure(constraints) } layout(width, height) { placeables.forEach { it.place(x, y) } } }
  23. Layout( content = { // Composables ... } ) {

    measurables, constraints -> val placeables = measurables.map { it.measure(constraints) } layout(width, height) { placeables.forEach { it.place(x, y) } } } ©ATLUS ©SEGA
  24. Layout( content = { Avatar() TextBox() } ) { (avatarMeasurable,

    textMeasurable), constraints -> } ©ATLUS ©SEGA
  25. Layout( content = { Avatar() TextBox() } ) { (avatarMeasurable,

    textMeasurable), constraints -> } ©ATLUS ©SEGA
  26. Layout( content = { Avatar() TextBox() } ) { (avatarMeasurable,

    textMeasurable), constraints -> val avatarPlaceable = avatarMeasurable.measure(constraints) } ©ATLUS ©SEGA
  27. Layout( content = { Avatar() TextBox() } ) { (avatarMeasurable,

    textMeasurable), constraints -> val avatarPlaceable = avatarMeasurable.measure(constraints) val textMaxWidth = constraints.maxWidth - avatarPlaceable.width + 18.dp } ©ATLUS ©SEGA
  28. Layout( content = { Avatar() TextBox() } ) { (avatarMeasurable,

    textMeasurable), constraints -> val avatarPlaceable = avatarMeasurable.measure(constraints) val textMaxWidth = constraints.maxWidth - avatarPlaceable.width + 18.dp } ©ATLUS ©SEGA
  29. Layout( content = { Avatar() TextBox() } ) { (avatarMeasurable,

    textMeasurable), constraints -> val avatarPlaceable = avatarMeasurable.measure(constraints) val textMaxWidth = constraints.maxWidth - avatarPlaceable.width + 18.dp val textConstraints = constraints.copy(maxWidth = textMaxWidth) val textPlaceable = textMeasurable.measure(textConstraints) } ©ATLUS ©SEGA
  30. Layout( content = { Avatar() TextBox() } ) { (avatarMeasurable,

    textMeasurable), constraints -> val avatarPlaceable = avatarMeasurable.measure(constraints) val textMaxWidth = constraints.maxWidth - avatarPlaceable.width + 18.dp val textConstraints = constraints.copy(maxWidth = textMaxWidth) val textPlaceable = textMeasurable.measure(textConstraints) val width = avatarPlaceable.width + textPlaceable.width - 18.dp val height = maxOf(avatarPlaceable.height, textPlaceable.height) } ©ATLUS ©SEGA
  31. Layout( content = { Avatar() TextBox() } ) { (avatarMeasurable,

    textMeasurable), constraints -> val avatarPlaceable = avatarMeasurable.measure(constraints) val textMaxWidth = constraints.maxWidth - avatarPlaceable.width + 18.dp val textConstraints = constraints.copy(maxWidth = textMaxWidth) val textPlaceable = textMeasurable.measure(textConstraints) val width = avatarPlaceable.width + textPlaceable.width - 18.dp val height = maxOf(avatarPlaceable.height, textPlaceable.height) layout(width, height) { avatarPlaceable.place(0, 0) } } ©ATLUS ©SEGA
  32. } ) { (avatarMeasurable, textMeasurable), constraints -> val avatarPlaceable =

    avatarMeasurable.measure(constraints) val textMaxWidth = constraints.maxWidth - avatarPlaceable.width + 18.dp val textConstraints = constraints.copy(maxWidth = textMaxWidth) val textPlaceable = textMeasurable.measure(textConstraints) val width = avatarPlaceable.width + textPlaceable.width - 18.dp val height = maxOf(avatarPlaceable.height, textPlaceable.height) layout(width, height) { avatarPlaceable.place(0, 0) val textBoxX = avatarPlaceable.width - textBoxOverlap val textBoxY = if (textPlaceable.height > avatarPlaceable.height) 0 else height - textPlaceable.height textPlaceable.place(textBoxX, textBoxY) } } ©ATLUS ©SEGA
  33. } ) { (avatarMeasurable, textMeasurable), constraints -> val avatarPlaceable =

    avatarMeasurable.measure(constraints) val textMaxWidth = constraints.maxWidth - avatarPlaceable.width + 18.dp val textConstraints = constraints.copy(maxWidth = textMaxWidth) val textPlaceable = textMeasurable.measure(textConstraints) val width = avatarPlaceable.width + textPlaceable.width - 18.dp val height = maxOf(avatarPlaceable.height, textPlaceable.height) layout(width, height) { avatarPlaceable.place(0, 0) val textBoxX = avatarPlaceable.width - textBoxOverlap val textBoxY = if (textPlaceable.height > avatarPlaceable.height) 0 else height - textPlaceable.height textPlaceable.place(textBoxX, textBoxY) } } ©ATLUS ©SEGA
  34. LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), contentPadding = WindowInsets.systemBars .add(WindowInsets(top = 100.dp,

    bottom = 100.dp)) .asPaddingValues(), modifier = Modifier.fillMaxSize(), ) ©ATLUS ©SEGA
  35. Centres itself to the first item Alternates left and right

    by a random offset Centres itself to the last item ©ATLUS ©SEGA
  36. Centres itself to the first item Alternates left and right

    by a random offset Centres itself to the last item Jumps to a final position before animating ©ATLUS ©SEGA
  37. Modelling data enum class Sender( @DrawableRes val image: Int, val

    color: Color, ) { Kasumi(R.drawable.kasumi, Color(0xFFD53359)), Ryuji(R.drawable.ryuji, Color(0xFFF0EA40)), Ann(R.drawable.ann, Color(0xFFFE93C9)), } Modelling data ©ATLUS ©SEGA
  38. Modelling data Modelling data data class Message( val sender: Sender,

    val text: String, ) enum class Sender( @DrawableRes val image: Int, val color: Color, ) { Kasumi(R.drawable.kasumi, Color(0xFFD53359)), Ryuji(R.drawable.ryuji, Color(0xFFF0EA40)), Ann(R.drawable.ann, Color(0xFFFE93C9)), } ©ATLUS ©SEGA
  39. Modelling data Modelling data data class Entry( val message: Message,

    val lineCoordinates: LineCoordinates, ) data class LineCoordinates( val leftPoint: Offset, val rightPoint: Offset, ) ©ATLUS ©SEGA
  40. Where to store these data? ViewModel is one option UI

    state is another option ©ATLUS ©SEGA
  41. Where to store these data? ViewModel is one option UI

    state is another option val scrollState = rememberScrollState() val pagerState = rememberPagerState() val textState = rememberTextFieldState() ©ATLUS ©SEGA
  42. @Composable fun rememberTranscriptState(): TranscriptState { val density = LocalDensity.current val

    coroutineScope = rememberCoroutineScope() return remember(density) { TranscriptState(density, coroutineScope) } }
  43. @Composable fun rememberTranscriptState(): TranscriptState { val density = LocalDensity.current val

    coroutineScope = rememberCoroutineScope() return remember(density) { TranscriptState(density, coroutineScope) } }
  44. @Stable class TranscriptState internal constructor( private val density: Density, private

    val coroutineScope: CoroutineScope, ) { private val _entries = mutableStateOf<List<Entry >> (emptyList()) val entries: List<Entry> by _entries }
  45. messageText [ ] messageText messageText [ ] , val entries:

    List<Entry> by _entries ©ATLUS ©SEGA
  46. messageText [ ] messageText messageText [ ] , messageText messageText

    messageText [ ] , , val entries: List<Entry> by _entries ©ATLUS ©SEGA
  47. data class Entry( val message: Message, val lineCoordinates: LineCoordinates, )

    val leftX = (AvatarSize.width / 2f) - (lineWidth / 2f) ©ATLUS ©SEGA
  48. val leftX = (AvatarSize.width / 2f) - (lineWidth / 2f)

    val rightX = leftX + lineWidth data class Entry( val message: Message, val lineCoordinates: LineCoordinates, ) ©ATLUS ©SEGA
  49. val leftX = (AvatarSize.width / 2f) - (lineWidth / 2f)

    val rightX = leftX + lineWidth val y = AvatarSize.height / 2f data class Entry( val message: Message, val lineCoordinates: LineCoordinates, ) ©ATLUS ©SEGA
  50. val leftX = (AvatarSize.width / 2f) - (lineWidth / 2f)

    val rightX = leftX + lineWidth val y = AvatarSize.height / 2f data class Entry( val message: Message, val lineCoordinates: LineCoordinates, ) val lineCoordinates = LineCoordinates( leftPoint = Offset(leftX, y), rightPoint = Offset(rightX, y), ) ©ATLUS ©SEGA
  51. val leftX = (AvatarSize.width / 2f) - (lineWidth / 2f)

    val rightX = leftX + lineWidth val y = AvatarSize.height / 2f data class Entry( val message: Message, val lineCoordinates: LineCoordinates, ) val lineCoordinates = LineCoordinates( leftPoint = Offset(leftX, y), rightPoint = Offset(rightX, y), ) ©ATLUS ©SEGA
  52. val leftX = (AvatarSize.width / 2f) - (lineWidth / 2f)

    val rightX = leftX + lineWidth val y = AvatarSize.height / 2f data class Entry( val message: Message, val lineCoordinates: LineCoordinates, ) val lineCoordinates = LineCoordinates( leftPoint = Offset(leftX, y), rightPoint = Offset(rightX, y), ) ©ATLUS ©SEGA
  53. val leftX = (AvatarSize.width / 2f) - (lineWidth / 2f)

    val rightX = leftX + lineWidth val y = AvatarSize.height / 2f data class Entry( val message: Message, val lineCoordinates: LineCoordinates, ) val direction = if (message.index % 2 = = 0) 1f else -1f ©ATLUS ©SEGA
  54. val leftX = (AvatarSize.width / 2f) - (lineWidth / 2f)

    val rightX = leftX + lineWidth val y = AvatarSize.height / 2f data class Entry( val message: Message, val lineCoordinates: LineCoordinates, ) val direction = if (message.index % 2 = = 0) 1f else -1f val horizontalShift = randomBetween(MinShift, MaxShift) * direction ©ATLUS ©SEGA
  55. val leftX = (AvatarSize.width / 2f) - (lineWidth / 2f)

    val rightX = leftX + lineWidth val y = AvatarSize.height / 2f data class Entry( val message: Message, val lineCoordinates: LineCoordinates, ) val direction = if (message.index % 2 = = 0) 1f else -1f val horizontalShift = randomBetween(MinShift, MaxShift) * direction val lineCoordinates = LineCoordinates( leftPoint = Offset(leftX + horizontalShift, y), rightPoint = Offset(leftX + horizontalShift, y), ) ©ATLUS ©SEGA
  56. data class Entry( val message: Message, val lineCoordinates: LineCoordinates, )

    val leftX = (AvatarSize.width / 2f) - (lineWidth / 2f) val rightX = leftX + lineWidth val y = AvatarSize.height / 2f val direction = if (message.index % 2 = = 0) 1f else -1f val horizontalShift = randomBetween(MinShift, MaxShift) * direction val lineCoordinates = LineCoordinates( leftPoint = Offset(leftX + horizontalShift, y), rightPoint = Offset(leftX + horizontalShift, y), ) ©ATLUS ©SEGA
  57. data class Entry( val message: Message, val lineCoordinates: LineCoordinates, )

    val leftX = (AvatarSize.width / 2f) - (lineWidth / 2f) val rightX = leftX + lineWidth val y = AvatarSize.height / 2f val direction = if (message.index % 2 = = 0) 1f else -1f val horizontalShift = randomBetween(MinShift, MaxShift) * direction val lineCoordinates = LineCoordinates( leftPoint = Offset(leftX + horizontalShift, y), rightPoint = Offset(leftX + horizontalShift, y), ) ©ATLUS ©SEGA
  58. data class Entry( val message: Message, val lineCoordinates: LineCoordinates, )

    val leftX = (AvatarSize.width / 2f) - (lineWidth / 2f) val rightX = leftX + lineWidth val y = AvatarSize.height / 2f val direction = if (message.index % 2 = = 0) 1f else -1f val horizontalShift = randomBetween(MinShift, MaxShift) * direction val lineCoordinates = LineCoordinates( leftPoint = Offset(leftX + horizontalShift, y), rightPoint = Offset(leftX + horizontalShift, y), ) ©ATLUS ©SEGA
  59. val listState = rememberLazyListState() val transcriptState = rememberTranscriptState() val entries

    = transcriptState.entries LazyColumn(state = listState) ©ATLUS ©SEGA
  60. val listState = rememberLazyListState() val transcriptState = rememberTranscriptState() val entries

    = transcriptState.entries LazyColumn(state = listState) { items(entries) { entry -> Entry(entry) } } ©ATLUS ©SEGA
  61. val listState = rememberLazyListState() val transcriptState = rememberTranscriptState() val entries

    = transcriptState.entries BackgroundLine(listState, entries) LazyColumn(state = listState) { items(entries) { entry -> Entry(entry) } } ©ATLUS ©SEGA
  62. @Composable private fun BackgroundLine( entries: List<Entry>, listState: LazyListState, ) {

    val visibleItemInfos = listState.layoutInfo.visibleItemsInfo } ©ATLUS ©SEGA
  63. @Composable private fun BackgroundLine( entries: List<Entry>, listState: LazyListState, ) {

    val visibleItemInfos = listState.layoutInfo.visibleItemsInfo } interface LazyListItemInfo { /** * The main axis offset of the item in pixels. * It is relative to the start of the lazy list container. */ val offset: Int } ©ATLUS ©SEGA
  64. @Composable private fun BackgroundLine( entries: List<Entry>, listState: LazyListState, ) {

    val visibleItemInfos = listState.layoutInfo.visibleItemsInfo } interface LazyListItemInfo { /** * The main axis offset of the item in pixels. * It is relative to the start of the lazy list container. */ val offset: Int } ©ATLUS ©SEGA
  65. @Composable private fun BackgroundLine( entries: List<Entry>, listState: LazyListState, ) {

    val visibleItemInfos = listState.layoutInfo.visibleItemsInfo } interface LazyListItemInfo { /** * The main axis offset of the item in pixels. * It is relative to the start of the lazy list container. */ v​ al offset: Int } ©ATLUS ©SEGA
  66. @Composable private fun BackgroundLine( entries: List<Entry>, listState: LazyListState, ) {

    val visibleItemInfos = listState.layoutInfo.visibleItemsInfo Canvas(modifier = Modifier.fillMaxSize()) { for (info in visibleItemInfos) { drawPath(getPoints(info, entries[info.index]), Color.Black) } } } interface LazyListItemInfo { * * The main axis offset of the item in pixels. * It is relative to the start of the lazy list container. / v​ } ©ATLUS ©SEGA
  67. fun Modifier.drawConnectingLine(entry1: Entry, entry2: Entry?): Modifier { if (entry2 ==

    null) return this return drawWithCache { onDrawBehind { } } } ©ATLUS ©SEGA
  68. fun Modifier.drawConnectingLine(entry1: Entry, entry2: Entry?): Modifier { if (entry2 ==

    null) return this return drawWithCache { val linePath = Path() val topLeft = entry1.lineCoordinates.leftPoint val topRight = entry1.lineCoordinates.rightPoint val bottomLeft = entry2.lineCoordinates.leftPoint + bottomOffset val bottomRight = entry2.lineCoordinates.rightPoint + bottomOffset onDrawBehind { } } } ©ATLUS ©SEGA
  69. fun Modifier.drawConnectingLine(entry1: Entry, entry2: Entry?): Modifier { if (entry2 ==

    null) return this return drawWithCache { val linePath = Path() val topLeft = entry1.lineCoordinates.leftPoint val topRight = entry1.lineCoordinates.rightPoint val bottomLeft = entry2.lineCoordinates.leftPoint + bottomOffset val bottomRight = entry2.lineCoordinates.rightPoint + bottomOffset onDrawBehind { with(linePath) { rewind() moveTo(topLeft.x, topLeft.y) lineTo(topRight.x, topRight.y) lineTo(bottomRight.x, bottomRight.y) lineTo(bottomLeft.x, bottomLeft.y) close() } drawPath(linePath, Color.Black) } } ©ATLUS ©SEGA
  70. val bottomRight = entry2.lineCoordinates.rightPoint + bottomOffset val shadowPaint = Paint().apply

    { color = Color.Black alpha = 0.5f asFrameworkPaint().maskFilter = BlurMaskFilter(4.dp.toPx(), NORMAL) } onDrawBehind { with(linePath) { rewind() moveTo(topLeft.x, topLeft.y) lineTo(topRight.x, topRight.y) lineTo(bottomRight.x, bottomRight.y) lineTo(bottomLeft.x, bottomLeft.y) close() } translate(top = 16.dp.toPx()) { drawIntoCanvas { it.drawPath(linePath, shadowPaint) } } drawPath(linePath, Color.Black) } } ©ATLUS ©SEGA
  71. ©ATLUS ©SEGA @Composable fun Portraits(senders: List<Sender>) { val displayModels =

    remember(senders) { val darkAvatarIndex = Random.nextInt(senders.size) senders.shuffled().mapIndexed { index, sender -> sender.getDisplayModel( darkAvatar = darkAvatarIndex == index, ) } } Canvas {
  72. ©ATLUS ©SEGA data class PortraitDisplayModel( val image: ImageBitmap, val middlePath:

    Path, val innerPath: Path, val imageOffset: Offset, val outerRotation: Float, val middleRotation: Float, val innerRotation: Float, val horizontalOffset: Float, val verticalOffset: Float, val darkAvatar: Boolean, )
  73. ©ATLUS ©SEGA data class PortraitDisplayModel( val image: ImageBitmap, val middlePath:

    Path, val innerPath: Path, val imageOffset: Offset, val outerRotation: Float, val middleRotation: Float, val innerRotation: Float, val horizontalOffset: Float, val verticalOffset: Float, val darkAvatar: Boolean, )
  74. ©ATLUS ©SEGA data class PortraitDisplayModel( val image: ImageBitmap, val middlePath:

    Path, val innerPath: Path, val imageOffset: Offset, val outerRotation: Float, val middleRotation: Float, val innerRotation: Float, val horizontalOffset: Float, val verticalOffset: Float, val darkAvatar: Boolean, )
  75. ©ATLUS ©SEGA data class PortraitDisplayModel( val image: ImageBitmap, val middlePath:

    Path, val innerPath: Path, val imageOffset: Offset, val outerRotation: Float, val middleRotation: Float, val innerRotation: Float, val horizontalOffset: Float, val verticalOffset: Float, val darkAvatar: Boolean, )
  76. Canvas { var stride = 0f val rotationPivot = Offset(

    x = PortraitSize.width.toPx(), y = PortraitSize.height.toPx(), ) for (model in portraitDisplayModels) { } } ©ATLUS ©SEGA
  77. Canvas { var stride = 0f val rotationPivot = Offset(

    x = PortraitSize.width.toPx(), y = PortraitSize.height.toPx(), ) for (model in portraitDisplayModels) { stride += model.horizontalOffset withTransform( transformBlock = { translate(stride + model.horizontalOffset, model.verticalOffset) rotate(degrees = model.outerRotation, pivot = rotationPivot) } ) { drawRect(Color.Black, size = PortraitSize.toSize()) } } ©ATLUS ©SEGA
  78. Canvas { var stride = 0f val rotationPivot = Offset(

    x = PortraitSize.width.toPx(), y = PortraitSize.height.toPx(), ) for (model in portraitDisplayModels) { stride += model.horizontalOffset withTransform( transformBlock = { translate(stride + model.horizontalOffset, model.verticalOffset) rotate(degrees = model.outerRotation, pivot = rotationPivot) } ) { drawRect(Color.Black, size = PortraitSize.toSize()) } stride += PortraitSize.width.toPx() } ©ATLUS ©SEGA
  79. Canvas { var stride = 0f val rotationPivot = Offset(

    x = PortraitSize.width.toPx(), y = PortraitSize.height.toPx(), ) for (model in portraitDisplayModels) { stride += model.horizontalOffset withTransform( transformBlock = { translate(stride + model.horizontalOffset, model.verticalOffset) rotate(degrees = model.outerRotation, pivot = rotationPivot) } ) { drawRect(Color.Black, size = PortraitSize.toSize()) } stride += PortraitSize.width.toPx() } stride = 0f for (model in portraitDisplayModels) { } ©ATLUS ©SEGA
  80. withTransform( transformBlock = { translate(stride + model.horizontalOffset, model.verticalOffset) rotate(degrees =

    model.outerRotation, pivot = rotationPivot) } ) { drawRect(Color.Black, size = PortraitSize.toSize()) } stride += PortraitSize.width.toPx() } stride = 0f for (model in portraitDisplayModels) { stride += model.horizontalOffset withTransform( transformBlock = { translate(stride + model.horizontalOffset, model.verticalOffset) rotate(model.middleRotation, pivot = rotationPivot) } ) { drawPath(model.middlePath, Color.White) } } ©ATLUS ©SEGA
  81. } stride = 0f for (model in portraitDisplayModels) { stride

    += model.horizontalOffset withTransform( transformBlock = { translate(stride + model.horizontalOffset, model.verticalOffset) rotate(model.middleRotation, pivot = rotationPivot) } ) { drawPath(model.middlePath, Color.White) withTransform( transformBlock = { rotate(model.innerRotation, rotationPivot) clipPath(model.innerPath) }, ) { if (model.darkAvatar) { drawRect(Color.Black) } } } } ©ATLUS ©SEGA
  82. for (model in portraitDisplayModels) { stride += model.horizontalOffset withTransform( transformBlock

    = { translate(stride + model.horizontalOffset, model.verticalOffset) rotate(model.middleRotation, pivot = rotationPivot) } ) { drawPath(model.middlePath, Color.White) withTransform( transformBlock = { rotate(model.innerRotation, rotationPivot) clipPath(model.innerPath) }, ) { if (model.darkAvatar) { drawRect(Color.Black) } translate(left = model.imageOffset.x, top = model.imageOffset.y) { drawImage(model.image) } } } } ©ATLUS ©SEGA
  83. withTransform( transformBlock = { translate(stride + model.horizontalOffset, model.verticalOffset) rotate(model.middleRotation, pivot

    = rotationPivot) } ) { drawPath(model.middlePath, Color.White) withTransform( transformBlock = { rotate(model.innerRotation, rotationPivot) clipPath(model.innerPath) }, ) { if (model.darkAvatar) { drawRect(Color.Black) } translate(left = model.imageOffset.x, top = model.imageOffset.y) { drawImage(model.image) } } } stride += PortraitSize.width.toPx() } ©ATLUS ©SEGA
  84. data class Entry( val message: Message, val lineCoordinates: LineCoordinates, val

    lineProgress: State<Float>, val avatarBackgroundScale: State<Float>, val avatarForegroundScale: State<Float>, ) ©ATLUS ©SEGA
  85. data class Entry( val message: Message, val lineCoordinates: LineCoordinates, val

    lineProgress: State<Float>, val avatarBackgroundScale: State<Float>, val avatarForegroundScale: State<Float>, val messageHorizontalScale: State<Float>, val messageVerticalScale: State<Float>, val messageTextAlpha: State<Float>, ) ©ATLUS ©SEGA
  86. avatarBackgroundScale = Animatable(initialValue = 0.6f) .apply { coroutineScope.launch { animateTo(

    targetValue = 1f, animationSpec = tween( durationMillis = 300, easing = EaseOutBack, ), ) } } ©ATLUS ©SEGA
  87. avatarBackgroundScale: Animatable<Float, AnimationVector1D> = Animatable(initialValue = 0.6f) .apply { coroutineScope.launch

    { animateTo( targetValue = 1f, animationSpec = tween( durationMillis = 300, easing = EaseOutBack, ), ) } } ©ATLUS ©SEGA
  88. avatarBackgroundScale: State<Float> = Animatable(initialValue = 0.6f) .apply { coroutineScope.launch {

    animateTo( targetValue = 1f, animationSpec = tween( durationMillis = 300, easing = EaseOutBack, ), ) } } .asState() ©ATLUS ©SEGA
  89. avatarBackgroundScale = Animatable(initialValue = 0.6f) .apply { // . ..

    } avatarForegroundScale = Animatable(initialValue = 0.0f) .apply { coroutineScope.launch { delay(160L) snapTo(0.8f) animateTo( targetValue = 1f, animationSpec = tween( durationMillis = 150, easing = EaseOutBack, ), ) } } .asState() ©ATLUS ©SEGA
  90. avatarBackgroundScale = Animatable(initialValue = 0.6f) .apply { // . ..

    } avatarForegroundScale = Animatable(initialValue = 0.0f) .apply { coroutineScope.launch { delay(160L) snapTo(0.8f) animateTo( targetValue = 1f, animationSpec = tween( durationMillis = 150, easing = EaseOutBack, ), ) } } .asState() ©ATLUS ©SEGA
  91. Box( modifier = Modifier .size(AvatarSize) .scale(entry.avatarBackgroundScale.value) .drawBehind { // Previous

    drawing code } ) { Image( painter = painterResource(entry.message.sender.image), ) } ©ATLUS ©SEGA
  92. Box( modifier = Modifier .size(AvatarSize) .scale(entry.avatarBackgroundScale.value) .drawBehind { // Previous

    drawing code } ) { Image( painter = painterResource(entry.message.sender.image), modifier = Modifier.graphicsLayer { transformOrigin = TransformOrigin( pivotFractionX = 0.5f, pivotFractionY = 1.15f, ), scaleX = entry.avatarForegroundScale.value scaleY = entry.avatarForegroundScale.value } ) } ©ATLUS ©SEGA
  93. Box( modifier = Modifier .size(AvatarSize) .scale(entry.avatarBackgroundScale.value) .drawBehind { // Previous

    drawing code } ) { Image( painter = painterResource(entry.message.sender.image), modifier = Modifier.graphicsLayer { transformOrigin = TransformOrigin( pivotFractionX = 0.5f, pivotFractionY = 1.15f, ), scaleX = entry.avatarForegroundScale.value scaleY = entry.avatarForegroundScale.value } ) } ©ATLUS ©SEGA
  94. onDrawBehind { with(linePath) { rewind() moveTo(topLeft.x, topLeft.y) lineTo(topRight.x, topRight.y) lineTo

    (​ bottomRight.x, bottomRight.y) lineTo (​ bottomLeft.x, bottomLeft.y) close() } drawPath(linePath, Color.Black) } v​ al b​ ottomLeft = entry2.lineCoordinates.leftPoint v​ al b​ ottomRight = entry2.lineCoordinates.rightPoint The line ©ATLUS ©SEGA
  95. onDrawBehind { val currentBottomLeft = lerp( start = topLeft, stop

    = bottomLeft, fraction = entry1.lineProgress.value, ) val currentBottomRight = lerp( start = topRight, stop = bottomRight, fraction = entry1.lineProgress.value, ) with(linePath) { rewind() moveTo(topLeft.x, topLeft.y) lineTo(topRight.x, topRight.y) lineTo (​ currentBottomRight.x, currentBottomRight.y) lineTo (​ currentBottomLeft.x, currentBottomLeft.y) close() } drawPath(linePath, Color.Black) } ©ATLUS ©SEGA
  96. onDrawBehind { val currentBottomLeft: Offset = lerp( start = topLeft,

    stop = bottomLeft, fraction = entry1.lineProgress.value, ) val currentBottomRight: Offset = lerp( start = topRight, stop = bottomRight, fraction = entry1.lineProgress.value, ) with(linePath) { rewind() moveTo(topLeft.x, topLeft.y) lineTo(topRight.x, topRight.y) lineTo (​ currentBottomRight.x, currentBottomRight.y) lineTo (​ currentBottomLeft.x, currentBottomLeft.y) close() } drawPath(linePath, Color.Black) } ©ATLUS ©SEGA
  97. coroutineScope.launch { while (true) { delay(300) dotVisible1.value = true delay(300)

    dotVisible2.value = true delay(300) dotVisible3.value = true delay(400) dotVisible1.value = false delay(100) dotVisible2.value = false delay(100) dotVisible3.value = false } } ©ATLUS ©SEGA
  98. @Composable fun BackgroundParticles(season: Season, modifier: Modifier = Modifier) { val

    state = remember { ParticlesState() } state.season = season if (season == Season.NONE) return val frameTimeMillis by rememberFrameTimeMillis() Canvas(modifier = modifier.fillMaxSize()) { state.update(frameTimeMillis) state.particles.fastForEach { particle -> // Draw particle ... } } }
  99. @Composable fun BackgroundParticles(season: Season, modifier: Modifier = Modifier) { val

    state = remember { ParticlesState() } state.season = season if (season == Season.NONE) return val frameTimeMillis by rememberFrameTimeMillis() Canvas(modifier = modifier.fillMaxSize()) { state.update(frameTimeMillis) state.particles.fastForEach { particle -> // Draw particle ... } } }
  100. @Composable private fun rememberFrameTimeMillis(): LongState { val millisState = remember

    { mutableLongStateOf(0L) } LaunchedEffect(Unit) { val startTime = withFrameMillis { it } while (true) { withFrameMillis { frameTime -> millisState.longValue = frameTime - startTime } } } return millisState }
  101. @Composable private fun rememberFrameTimeMillis(): LongState { val millisState = remember

    { mutableLongStateOf(0L) } LaunchedEffect(Unit) { val startTime = withFrameMillis { it } while (true) { withFrameMillis { frameTime -> millisState.longValue = frameTime - startTime } } } return millisState }
  102. class ParticlesState { private val active = ArrayList<Particle>(MAX_PARTICLE_COUNT) private val

    pool = ArrayDeque<Particle>(MAX_PARTICLE_COUNT).apply { repeat(MAX_PARTICLE_COUNT) { add(Particle()) } } }
  103. class ParticlesState { private val active = ArrayList<Particle>(MAX_PARTICLE_COUNT) private val

    pool = ArrayDeque<Particle>(MAX_PARTICLE_COUNT).apply { repeat(MAX_PARTICLE_COUNT) { add(Particle()) } } val particles: List<Particle> get() = active fun update(time: Long, worldSize: DpSize) { } }
  104. class ParticlesState { private val active = ArrayList<Particle>(MAX_PARTICLE_COUNT) private val

    pool = ArrayDeque<Particle>(MAX_PARTICLE_COUNT).apply { repeat(MAX_PARTICLE_COUNT) { add(Particle()) } } val particles: List<Particle> get() = active private var lastUpdateTime = 0L fun update(time: Long, worldSize: DpSize) { if (time > nextSpawnTime && pool.isNotEmpty()) { // Move a particle from pool to active. } val deltaSeconds = (time - lastUpdateTime) / 1000f lastUpdateTime = time } }
  105. private var lastUpdateTime = 0L fun update(time: Long, worldSize: DpSize)

    { if (time > nextSpawnTime && pool.isNotEmpty()) { // Move a particle from pool to active. } val deltaSeconds = (time - lastUpdateTime) / 1000f for (index in active.indices.reversed()) { val particle = active[index] if ( particle.x < -DESPAWN_BUFFER_SIZE || particle.y > worldSize.height + DESPAWN_BUFFER_SIZE ) { active.remove(particle) pool.add(particle) } } lastUpdateTime = time } }
  106. if (time > nextSpawnTime && pool.isNotEmpty()) { // Move a

    particle from pool to active. } val deltaSeconds = (time - lastUpdateTime) / 1000f for (index in active.indices.reversed()) { val particle = active[index] if ( particle.x < -DESPAWN_BUFFER_SIZE || particle.y > worldSize.height + DESPAWN_BUFFER_SIZE ) { active.remove(particle) pool.add(particle) } else { with(particle) { rotation += rotationSpeed * deltaSeconds x += xSpeed * deltaSeconds y += (period * sin(amplitude * x.value) + (ySpeed.value * deltaSeconds)).dp } } } lastUpdateTime = time } }
  107. if (time > nextSpawnTime && pool.isNotEmpty()) { // Move a

    particle from pool to active. } val deltaSeconds = (time - lastUpdateTime) / 1000f for (index in active.indices.reversed()) { val particle = active[index] if ( particle.x < -DESPAWN_BUFFER_SIZE || particle.y > worldSize.height + DESPAWN_BUFFER_SIZE ) { active.remove(particle) pool.add(particle) } else { with(particle) { rotation += rotationSpeed * deltaSeconds x += xSpeed * deltaSeconds y += (period * sin(amplitude * x.value) + (ySpeed.value * deltaSecon } } } lastUpdateTime = time } }
  108. Takeaways Compose UI facilitates creativity Some UIs should be boring,

    but not all of them Maybe we lost some magic with all the consistency Video games can be source of inspiration
  109. Creative UIs with Compose github.com/chris-horner/persona-im All art and character designs

    in this presentation are the property of Atlus Co., Ltd. Material is used for reference and educational purposes only. © @[email protected]