Created
October 19, 2025 15:09
-
-
Save sdetilly/a169b49fee7e3ce377ae8fb8c84fad0b to your computer and use it in GitHub Desktop.
Using DrawScope to create custom overlays
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| data class Message( | |
| val id: Int, | |
| val text: String, | |
| val isFromMe: Boolean, | |
| var reaction: String? = null | |
| ) | |
| class MainActivity : ComponentActivity() { | |
| override fun onCreate(savedInstanceState: Bundle?) { | |
| super.onCreate(savedInstanceState) | |
| enableEdgeToEdge( | |
| statusBarStyle = SystemBarStyle.light( | |
| scrim = Color(0xFF1C1C1E).toArgb(), | |
| darkScrim = Color(0xFF1C1C1E).toArgb() | |
| ), | |
| navigationBarStyle = SystemBarStyle.dark(Color(0xFF1C1C1E).toArgb()) | |
| ) | |
| setContent { | |
| AndroidDemoTheme { | |
| MessagingApp() | |
| } | |
| } | |
| } | |
| } | |
| @Composable | |
| fun MessagingApp() { | |
| var messages by remember { | |
| mutableStateOf( | |
| listOf( | |
| Message(1, "Hey! How are you?", false), | |
| Message(2, "I'm doing great, thanks!", true), | |
| Message(3, "Want to grab lunch later?", false), | |
| Message(4, "Sure! What time works for you?", true), | |
| Message(5, "How about 12:30?", false), | |
| Message(6, "Perfect! See you then", true), | |
| Message(7, "Looking forward to it!", false) | |
| ) | |
| ) | |
| } | |
| var selectedMessageId by remember { mutableStateOf<Int?>(null) } | |
| var selectedMessageBounds by remember { mutableStateOf<Rect?>(null) } | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .statusBarsPadding() | |
| .background(Color(0xFF1C1C1E)) | |
| ) { | |
| // Main chat UI | |
| Column( | |
| modifier = Modifier.fillMaxSize() | |
| ) { | |
| // Header | |
| Surface( | |
| modifier = Modifier.fillMaxWidth(), | |
| color = Color(0xFF1C1C1E), | |
| shadowElevation = 4.dp | |
| ) { | |
| Text( | |
| text = "John Doe", | |
| modifier = Modifier.padding(16.dp), | |
| fontSize = 20.sp, | |
| fontWeight = FontWeight.Bold, | |
| color = Color.White | |
| ) | |
| } | |
| // Messages | |
| LazyColumn( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .padding(8.dp), | |
| verticalArrangement = Arrangement.spacedBy(8.dp) | |
| ) { | |
| items(messages, key = { it.id }) { message -> | |
| MessageBubble( | |
| message = message, | |
| isSelected = selectedMessageId == message.id, | |
| onLongPress = { bounds -> | |
| selectedMessageId = message.id | |
| selectedMessageBounds = bounds | |
| } | |
| ) | |
| } | |
| } | |
| } | |
| // Overlay with cutout effect | |
| if (selectedMessageId != null && selectedMessageBounds != null) { | |
| OverlayWithReactions( | |
| messageBounds = selectedMessageBounds!!, | |
| onDismiss = { | |
| selectedMessageId = null | |
| selectedMessageBounds = null | |
| }, | |
| onReactionSelected = { emoji -> | |
| messages = messages.map { | |
| when { | |
| it.id == selectedMessageId && it.reaction == null -> it.copy(reaction = emoji) | |
| it.id == selectedMessageId -> it.copy(reaction = null) | |
| else -> it | |
| } | |
| } | |
| selectedMessageId = null | |
| selectedMessageBounds = null | |
| } | |
| ) | |
| } | |
| } | |
| } | |
| @Composable | |
| fun MessageBubble( | |
| message: Message, | |
| isSelected: Boolean, | |
| onLongPress: (Rect) -> Unit | |
| ) { | |
| val scale = remember { Animatable(1f) } | |
| var bounds by remember { mutableStateOf<Rect?>(null) } | |
| LaunchedEffect(isSelected) { | |
| if (isSelected) { | |
| scale.animateTo(1.05f, animationSpec = spring()) | |
| } else { | |
| scale.animateTo(1f, animationSpec = spring()) | |
| } | |
| } | |
| Row( | |
| modifier = Modifier.fillMaxWidth(), | |
| horizontalArrangement = if (message.isFromMe) Arrangement.End else Arrangement.Start | |
| ) { | |
| Box { | |
| Box( | |
| modifier = Modifier | |
| // Use graphicsLayer instead of scale to reduce needless recompositions on changing values | |
| .graphicsLayer { | |
| scaleX = scale.value | |
| scaleY = scale.value | |
| } | |
| .onGloballyPositioned { coordinates -> | |
| bounds = coordinates.boundsInRoot() | |
| } | |
| .clip(RoundedCornerShape(16.dp)) | |
| .drawBehind { | |
| drawRoundRect( | |
| color = if (message.isFromMe) Color(0xFF0A84FF) else Color(0xFF2C2C2E), | |
| cornerRadius = CornerRadius(16.dp.toPx(), 16.dp.toPx()) | |
| ) | |
| } | |
| .pointerInput(Unit) { | |
| detectTapGestures( | |
| onLongPress = { | |
| bounds?.let { onLongPress(it) } | |
| } | |
| ) | |
| } | |
| .padding(12.dp) | |
| ) { | |
| Text( | |
| text = message.text, | |
| color = Color.White, | |
| fontSize = 16.sp | |
| ) | |
| } | |
| // Reaction emoji | |
| message.reaction?.let { emoji -> | |
| Box( | |
| modifier = Modifier | |
| .align(Alignment.BottomEnd) | |
| .offset(x = 4.dp, y = 16.dp) | |
| .background(Color(0xFF3A3A3C), RoundedCornerShape(12.dp)) | |
| .padding(horizontal = 6.dp, vertical = 2.dp) | |
| ) { | |
| Text( | |
| text = emoji, | |
| fontSize = 14.sp | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| @Composable | |
| fun OverlayWithReactions( | |
| messageBounds: Rect, | |
| onDismiss: () -> Unit, | |
| onReactionSelected: (String) -> Unit | |
| ) { | |
| val overlayAlpha = remember { Animatable(0f) } | |
| val emojiScale = remember { Animatable(0f) } | |
| LaunchedEffect(Unit) { | |
| launch { | |
| overlayAlpha.animateTo(0.7f, animationSpec = tween(200)) | |
| } | |
| launch { | |
| emojiScale.animateTo(1f, animationSpec = spring()) | |
| } | |
| } | |
| val statusBarInsets = WindowInsets.statusBars | |
| Box( | |
| modifier = Modifier.fillMaxSize() | |
| ) { | |
| // Dark overlay with cutout for the message | |
| Canvas( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| // Pointer input to dismiss the overlay when tapping anywhere on it | |
| .pointerInput(Unit) { | |
| detectTapGestures { | |
| onDismiss() | |
| } | |
| } | |
| ) { | |
| // Draw the full dark overlay | |
| val path = Path() | |
| path.addRect(Rect(0f, 0f, size.width, size.height)) | |
| val cornerRadius = 16.dp.toPx() //The same corner radius as the message bubble | |
| val scale = 1.05f // Scaling the message bubble uses when selected | |
| // Calculate how much the bubble expands on each side when scaled from center | |
| val extraWidth = messageBounds.width * (scale - 1f) / 2f | |
| val extraHeight = messageBounds.height * (scale - 1f) / 2f | |
| // Create the cutout path to perfectly match the scaled bubble | |
| val holePath = Path() | |
| holePath.addRoundRect( | |
| RoundRect( | |
| left = messageBounds.left - extraWidth, | |
| top = messageBounds.top - extraHeight, | |
| right = messageBounds.right + extraWidth, | |
| bottom = messageBounds.bottom + extraHeight, | |
| cornerRadius = CornerRadius(cornerRadius) | |
| ).translate(Offset(x = 0f, y = -statusBarInsets.getTop(this).toFloat())) | |
| ) | |
| // Here we draw the overlay | |
| drawPath( | |
| path = path.minus(holePath), | |
| color = Color.Black.copy(alpha = 0.6f) | |
| ) | |
| } | |
| // Emoji reaction selector | |
| BoxWithConstraints(modifier = Modifier.fillMaxSize()) { | |
| val density = LocalDensity.current | |
| val emojis = listOf("โค๏ธ", "๐", "๐", "๐ฎ", "๐ข") | |
| val screenWidth = maxWidth | |
| // Calculate emoji selector width | |
| // 5 emojis * 30dp + 4 gaps * 8dp + horizontal padding * 2 * 16dp | |
| val emojiSelectorWidth = (5 * 30 + 4 * 8 + 2 * 16).dp | |
| // Calculate x position, ensuring it stays on screen | |
| val xPosition = with(density) { | |
| val idealX = messageBounds.left.toDp() | |
| val maxX = screenWidth - emojiSelectorWidth - 8.dp // 8dp margin from edge | |
| if (idealX + emojiSelectorWidth > screenWidth) { | |
| maxX.coerceAtLeast(8.dp) | |
| } else { | |
| idealX | |
| } | |
| } | |
| Box( | |
| modifier = Modifier | |
| .offset { | |
| IntOffset( | |
| x = xPosition.roundToPx(), | |
| y = (messageBounds.bottom + 4.dp.toPx()).roundToInt() - statusBarInsets.getTop(this) | |
| ) | |
| } | |
| .graphicsLayer { | |
| scaleX = emojiScale.value | |
| scaleY = emojiScale.value | |
| } | |
| .clip(RoundedCornerShape(24.dp)) | |
| .background(Color(0xFF2C2C2E)) | |
| .padding(horizontal = 16.dp, vertical = 12.dp) | |
| ) { | |
| Row( | |
| horizontalArrangement = Arrangement.spacedBy(12.dp) | |
| ) { | |
| emojis.forEach { emoji -> | |
| Box( | |
| modifier = Modifier | |
| .size(30.dp) | |
| .clip(RoundedCornerShape(20.dp)) | |
| .background(Color(0xFF3A3A3C)) | |
| .pointerInput(Unit) { | |
| detectTapGestures { | |
| onReactionSelected(emoji) | |
| } | |
| }, | |
| contentAlignment = Alignment.Center | |
| ) { | |
| Text( | |
| text = emoji, | |
| fontSize = 15.sp | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment