Skip to content

Instantly share code, notes, and snippets.

@sdetilly
Created October 19, 2025 15:09
Show Gist options
  • Select an option

  • Save sdetilly/a169b49fee7e3ce377ae8fb8c84fad0b to your computer and use it in GitHub Desktop.

Select an option

Save sdetilly/a169b49fee7e3ce377ae8fb8c84fad0b to your computer and use it in GitHub Desktop.
Using DrawScope to create custom overlays
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