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

Creative UIs with Compose: DroidKaigi 2024

Chris Horner
September 13, 2024

Creative UIs with Compose: DroidKaigi 2024

A talk from DroidKaigi 2024. While most of the time following Material recommendations on Android makes sense, Compose UI allows us to be more expressive than ever before.

This talk walks through how mobile 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 on Android.

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

Chris Horner

September 13, 2024
Tweet

More Decks by Chris Horner

Other Decks in Technology

Transcript

  1. We ’ re going to analyse and build a complex

    UI But first… ҰॹʹෳࡶͳUIͷௐࠪͱߏஙΛߦ͍·͢ɻ ·ͣɻɻɻ
  2. - A designer I worked with I arrange rectangles. Sometimes,

    if I want to get fancy, I round the corners. ࢲ͸ɺ࢛֯ܗΛ࡞੒͠·ͨ͠ɻ ֯ΛؙΊͯɺ͓͠ΌΕʹ͍ͨ͠Ͱ͢ɻ - σβΠφʔ༑ୡ
  3. Flat design ϑϥοτσβΠϯ Easy for a designer to put together

    σβΠφʔʹͱͬͯ͸؆୯ʹશͯΛ૊Έ߹ΘͤΒΕΔ Simple for a developer to build ։ൃऀʹͱͬͯߏங͕؆୯ͳ͜ͱʹͳΔ Low effort to read ಡΉͷʹख͕͔͔ؒΒͳ͍ Renders quickly ૉૣ͘ඳ্͖͛Δ Responsive ൓Ԡ͕͍͍ Dark mode μʔΫϞʔυ
  4. Boring 😴 is good Phone calls ి࿩ Customer support ΧελϚʔαʔϏε

    Maps / navigation ஍ਤʗφϏ Email ϝʔϧ But… have we gone too far? ͔͠͠...ߦ͖ա͗ͨͷͩΖ͏͔ʁ ฏຌͳ͍͍͜ͱͰ͢Ͷɻ
  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() ​)​ } ©ATLUS ©SEGA
  6. 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
  7. fun outerBox(): Shape = GenericShape { size -> moveTo(31.7, 3.1)

    lineTo(size.width, 0f) lineTo(size.width - 23, size.height) } ©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) } ©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 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
  11. 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
  12. 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
  13. Text( text = message.text, style = MaterialTheme.typography.bodyMedium, color = Color.White,

    fontFamily = OptimaNova, modifier = Modifier .drawBehind { val outerBox = outerBox() val innerBox = innerBox() } ) ©ATLUS ©SEGA
  14. 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
  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) } ) ©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. 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
  18. 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
  19. 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
  20. 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
  21. Layout( content = { // Composables ... } ) {

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

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

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

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

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

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

    textMeasurable), constraints -> val avatarPlaceable = avatarMeasurable.measure(constraints) } ©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. LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), contentPadding = WindowInsets.systemBars .add(WindowInsets(top = 100.dp,

    bottom = 100.dp)) .asPaddingValues(), modifier = Modifier.fillMaxSize(), ) ©ATLUS ©SEGA
  34. 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
  35. 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 σʔλϞσϧ
  36. Modelling data Modelling data data class Entry( val message: Message,

    val lineCoordinates: LineCoordinates, ) data class LineCoordinates( val leftPoint: Offset, val rightPoint: Offset, ) ©ATLUS ©SEGA σʔλϞσϧ
  37. 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 Ͳ͜ʹอ؅͢Δʁ
  38. ViewModel UI state Current list of messages ࠓ·Ͱͷϝοηʔδ͕Ϧετʹ Entries Background

    line coordinates എܠઢίʔσΟωʔτ Item animation progress ΞΠςϜΞχϝʔγϣϯͷ ਐߦঢ়گ
  39. @Composable fun rememberTranscriptState(): TranscriptState { val density = LocalDensity.current val

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

    coroutineScope = rememberCoroutineScope() return remember(density) { TranscriptState(density, coroutineScope) } }
  41. @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 }
  42. messageText [ ] messageText messageText [ ] , val entries:

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

    messageText [ ] , , val entries: List<Entry> by _entries ©ATLUS ©SEGA
  44. 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
  45. 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
  46. 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
  47. 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
  48. val listState = rememberLazyListState() val transcriptState = rememberTranscriptState() val entries

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

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

    = transcriptState.entries BackgroundLine(listState, entries) LazyColumn(state = listState) { items(entries) { entry -> Entry(entry) } } ©ATLUS ©SEGA
  51. @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
  52. @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
  53. @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
  54. fun Modifier.drawConnectingLine(entry1: Entry, entry2: Entry?): Modifier { if (entry2 ==

    null) return this return drawWithCache { onDrawBehind { } } } ©ATLUS ©SEGA
  55. 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
  56. 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
  57. 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
  58. data class Entry( val message: Message, val lineCoordinates: LineCoordinates, val

    lineProgress: State<Float>, val avatarBackgroundScale: State<Float>, val avatarForegroundScale: State<Float>, ) ©ATLUS ©SEGA
  59. 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
  60. avatarBackgroundScale = Animatable(initialValue = 0.6f) .apply { coroutineScope.launch { animateTo(

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

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

    animateTo( targetValue = 1f, animationSpec = tween( durationMillis = 300, easing = EaseOutBack, ), ) } } .asState() ©ATLUS ©SEGA
  63. 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
  64. 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
  65. Box( modifier = Modifier .size(AvatarSize) .scale(entry.avatarBackgroundScale.value) .drawBehind { // Previous

    drawing code } ) { Image( painter = painterResource(entry.message.sender.image), ) } ©ATLUS ©SEGA
  66. 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
  67. 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
  68. 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 ઢ Design property of Atlus Co., Ltd
  69. 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
  70. 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
  71. Takeaways ςΠΫΞ΢τ Compose UI facilitates creativity Compose UI͕ಠ૑ੑΛߴΊΔ Some UIs

    should be boring, but not all of them Ұ෦ͷUI͸ୀ۶Ͱ͋Δ΂͖͕ͩɺ͢΂ͯͰ͸ͳ͍ Maybe we lost some magic with all the consistency Ұ؏ੑ͕ແ͘ͳͬͨ͜ͱͰɺϚδοΫ͕ࣦΘΕͯ͠·ͬͨͷ͔΋͠Εͳ͍ɻ Video games can be source of inspiration ήʔϜ͸ൃ૝ͷݯʹͳΔ
  72. Creative UIs with Compose github.com/chris-horner/persona-im [email protected] All art and character

    designs in this presentation are the property of Atlus Co., Ltd. Material used for reference and educational purposes only. ©