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 = 0f when 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+

Get New Tutorials by Email

No spam. Just clear, practical breakdowns you can apply right away.

Enjoy this tutorial?

Get new practical tech tutorials in your inbox.