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
}
}
}
}
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
}
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
}
}
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)
}
}
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)
}
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)