DEV Community

StareBrain
StareBrain

Posted on

Building StareBrain, Part 17: Drag-to-Reposition and Persisting Overlay Position

Building StareBrain, Part 17: Drag-to-Reposition and Persisting Overlay Position

Part 16 introduced the overlay window — the floating UI layer that shows task progress and surfaces confirmation prompts. One problem we hit immediately in testing: the overlay covers UI the user needs to interact with.

The fix is drag-to-reposition. The user holds and drags the overlay anywhere on screen. When they reopen StareBrain, it's where they left it.

Here's how we built it.


Touch Handling

The overlay uses a View.OnTouchListener to track drag gestures. We calculate delta from the initial touch position and update WindowManager.LayoutParams on every ACTION_MOVE.

class OverlayView(context: Context) : FrameLayout(context) {

    private var initialX = 0
    private var initialY = 0
    private var initialTouchX = 0f
    private var initialTouchY = 0f

    var onPositionChanged: ((Int, Int) -> Unit)? = null

    init {
        setOnTouchListener { _, event ->
            val params = layoutParams as WindowManager.LayoutParams
            when (event.action) {
                MotionEvent.ACTION_DOWN -> {
                    initialX = params.x
                    initialY = params.y
                    initialTouchX = event.rawX
                    initialTouchY = event.rawY
                    true
                }
                MotionEvent.ACTION_MOVE -> {
                    val dx = (event.rawX - initialTouchX).toInt()
                    val dy = (event.rawY - initialTouchY).toInt()
                    params.x = initialX + dx
                    params.y = initialY + dy
                    val wm = context.getSystemService(WindowManager::class.java)
                    wm.updateViewLayout(this, params)
                    true
                }
                MotionEvent.ACTION_UP -> {
                    val params2 = layoutParams as WindowManager.LayoutParams
                    onPositionChanged?.invoke(params2.x, params2.y)
                    true
                }
                else -> false
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

onPositionChanged fires on ACTION_UP — once the drag is complete, not on every pixel. This avoids thrashing the persistence layer during the move.


Distinguishing Drag from Tap

A short touch that doesn't move should still register as a tap (expand/collapse, confirm/reject). We distinguish by checking displacement:

MotionEvent.ACTION_UP -> {
    val dx = event.rawX - initialTouchX
    val dy = event.rawY - initialTouchY
    val displacement = Math.sqrt((dx * dx + dy * dy).toDouble())
    if (displacement < DRAG_THRESHOLD_PX) {
        performClick()
    } else {
        val params2 = layoutParams as WindowManager.LayoutParams
        onPositionChanged?.invoke(params2.x, params2.y)
    }
    true
}

companion object {
    private const val DRAG_THRESHOLD_PX = 10f
}
Enter fullscreen mode Exit fullscreen mode

Under 10px of movement → tap. Over 10px → drag and persist.


Persisting Position via SessionMemory

We store overlay position in SessionMemory alongside behavioral patterns. Position is not sensitive data — it's a UI preference.

data class OverlayPosition(
    val x: Int,
    val y: Int
)

class SessionMemory(private val context: Context) {

    private val prefs = context.getSharedPreferences("session_memory", Context.MODE_PRIVATE)

    fun saveOverlayPosition(position: OverlayPosition) {
        prefs.edit()
            .putInt("overlay_x", position.x)
            .putInt("overlay_y", position.y)
            .apply()
    }

    fun loadOverlayPosition(): OverlayPosition {
        val x = prefs.getInt("overlay_x", DEFAULT_X)
        val y = prefs.getInt("overlay_y", DEFAULT_Y)
        return OverlayPosition(x, y)
    }

    companion object {
        private const val DEFAULT_X = 16
        private const val DEFAULT_Y = 120
    }
}
Enter fullscreen mode Exit fullscreen mode

Restoring Position on Launch

In OverlayManager.show(), we load the saved position before adding the view:

fun show() {
    if (overlayView != null) return
    val savedPosition = sessionMemory.loadOverlayPosition()
    val params = WindowManager.LayoutParams(
        WindowManager.LayoutParams.WRAP_CONTENT,
        WindowManager.LayoutParams.WRAP_CONTENT,
        WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
        WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
            WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
        PixelFormat.TRANSLUCENT
    ).apply {
        gravity = Gravity.TOP or Gravity.START
        x = savedPosition.x
        y = savedPosition.y
    }
    overlayView = OverlayView(context).also { view ->
        view.onPositionChanged = { x, y ->
            sessionMemory.saveOverlayPosition(OverlayPosition(x, y))
        }
        windowManager.addView(view, params)
    }
}
Enter fullscreen mode Exit fullscreen mode

Note the gravity change: Gravity.TOP or Gravity.START instead of TOP or END. When we save absolute x/y coordinates, we need a fixed anchor — START gives us that. END would mirror the offset and place the overlay in the wrong position after a restore.


Screen Boundary Clamping

If the user's screen resolution changes (rotation, different device), the saved position might be off-screen. We clamp on restore:

fun loadOverlayPosition(displayMetrics: DisplayMetrics): OverlayPosition {
    val x = prefs.getInt("overlay_x", DEFAULT_X)
    val y = prefs.getInt("overlay_y", DEFAULT_Y)
    val clampedX = x.coerceIn(0, displayMetrics.widthPixels - OVERLAY_WIDTH_PX)
    val clampedY = y.coerceIn(0, displayMetrics.heightPixels - OVERLAY_HEIGHT_PX)
    return OverlayPosition(clampedX, clampedY)
}
Enter fullscreen mode Exit fullscreen mode

What's Next

Part 18 will cover auto-collapse: the overlay shrinks to a dot when the user is actively interacting with another app, and expands when a step completes or confirmation is needed — without interrupting what the user is doing.

Top comments (0)