What You Will Build
A floating window overlay that the user can drag anywhere on screen, expand/collapse, and dismiss. The window appears with a scale+fade animation and has a title bar with window controls. This pattern is used for picture-in-picture video, floating chat heads, debug panels, and mini-player overlays.
Why Draggable Overlays Are a Power Pattern
Floating windows combine gesture detection, offset state management, animated visibility, and dynamic sizing. The detectDragGestures + offset pattern is the foundation for any draggable UI element: resizable panels, slider thumbs, or repositionable widgets.
Key Compose Concepts
- pointerInput with detectDragGestures for free-form dragging.
- Modifier.offset with IntOffset for positioning based on drag state.
- AnimatedVisibility with scaleIn/scaleOut for window show/hide transitions.
Step 1: State for Position and Visibility
var showFloating by remember { mutableStateOf(false) }
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
var isExpanded by remember { mutableStateOf(false) }
Step 2: The Draggable Window
AnimatedVisibility(
visible = showFloating,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut()
) {
Box(
modifier = Modifier
.offset {
IntOffset(
offsetX.roundToInt(),
offsetY.roundToInt()
)
}
.padding(16.dp)
.then(
if (isExpanded)
Modifier.fillMaxWidth(0.9f).height(300.dp)
else Modifier.size(200.dp, 160.dp)
)
.shadow(12.dp, RoundedCornerShape(16.dp))
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume()
offsetX += dragAmount.x
offsetY += dragAmount.y
}
}
) {
// Window content
}
}
Step 3: The Title Bar with Controls
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFF4A90D9))
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("Floating", color = Color.White,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f))
// Expand/collapse button
IconButton(
onClick = { isExpanded = !isExpanded },
modifier = Modifier.size(24.dp)
) {
Icon(
if (isExpanded) Icons.Default.CloseFullscreen
else Icons.Default.OpenInFull,
null, tint = Color.White,
modifier = Modifier.size(16.dp)
)
}
Spacer(Modifier.width(4.dp))
// Close button
IconButton(
onClick = { showFloating = false },
modifier = Modifier.size(24.dp)
) {
Icon(Icons.Default.Close, null,
tint = Color.White,
modifier = Modifier.size(16.dp))
}
}
Tips and Pitfalls
- change.consume() in detectDragGestures is critical. Without it, the drag gesture may conflict with parent scroll containers.
- Reset offset on show: Set
offsetX = 0f; offsetY = 0fwhen showing the window so it always appears at a predictable position. - shadow() before clip() in the modifier chain ensures the shadow renders outside the clipped bounds.
- Modifier.then() lets you conditionally apply different size modifiers based on the expanded state.
Minimum SDK: API 24+ with Compose BOM 2024.01+