Let the page be dragged shut

03e0527 · Gabriel Morell · 2026-09-02 21:51

11 files +81 -16
Message
{commit_body(@commit)}

Files changed

modified clients/android/app/src/main/java/io/neiam/apolloscrib/feed/OverlayPanel.kt
+81 −16
@@ -7,6 +7,8 @@ import android.os.Build
7 7 import android.os.IBinder
8 8 import android.util.Log
9 9 import android.view.Gravity
10 +import android.view.MotionEvent
11 +import android.view.ViewConfiguration
10 12 import android.view.View
11 13 import android.view.WindowManager
12 14 import android.widget.FrameLayout
@@ -38,7 +40,13 @@ import kotlin.math.abs
38 40 */
39 41 class OverlayPanel(
40 42 private val context: Context,
41 − private val onScrollSettled: (Float) -> Unit
43 + /**
44 + * Where the page has got to, for the launcher to move its workspace in
45 + * step. Called while this end drives -- a drag on the panel itself, or an
46 + * animation -- and not while the launcher does, since it is already
47 + * moving and the two would fight.
48 + */
49 + private val reportScroll: (Float) -> Unit
42 50 ) {
43 51
44 52 private val windowManager =
@@ -76,7 +84,7 @@ class OverlayPanel(
76 84 }
77 85 }
78 86
79 − val root = FrameLayout(context).apply {
87 + val root = PanelLayout(context).apply {
80 88 // Explicitly: a FrameLayout child defaults to WRAP_CONTENT, and a
81 89 // page that does not fill its window leaves the strips behind the
82 90 // system bars showing whatever is under it.
@@ -171,7 +179,7 @@ class OverlayPanel(
171 179 /** The launcher is dragging. Follow it exactly rather than animating. */
172 180 fun onScroll(value: Float) {
173 181 animator?.cancel()
174 − apply(value.coerceIn(0f, 1f))
182 + apply(value.coerceIn(0f, 1f), report = false)
175 183 }
176 184
177 185 /** The drag ended wherever it ended; settle to the nearer edge. */
@@ -197,29 +205,22 @@ class OverlayPanel(
197 205 private fun animateTo(target: Float) {
198 206 val from = progress
199 207 if (abs(from - target) < 0.001f) {
200 − onScrollSettled(target)
208 + reportScroll(target)
201 209 return
202 210 }
203 211 animator?.cancel()
204 212 animator = ValueAnimator.ofFloat(from, target).apply {
205 213 duration = SETTLE_MS
206 − addUpdateListener { apply(it.animatedValue as Float) }
207 − // Told at the end rather than throughout: the launcher moves its
208 − // own workspace while it is driving, and echoing every frame back
209 − // makes the two fight.
210 − addListener(
211 − object : android.animation.AnimatorListenerAdapter() {
212 − override fun onAnimationEnd(animation: android.animation.Animator) {
213 − onScrollSettled(target)
214 − }
215 − }
216 − )
214 + // Reported throughout: this end is driving, so the launcher's
215 + // workspace has to follow frame by frame or it jumps at the end.
216 + addUpdateListener { apply(it.animatedValue as Float, report = true) }
217 217 start()
218 218 }
219 219 }
220 220
221 − private fun apply(value: Float) {
221 + private fun apply(value: Float, report: Boolean) {
222 222 progress = value
223 + if (report) reportScroll(value)
223 224 view?.let { panel ->
224 225 panel.translationX = -width * (1f - value)
225 226 panel.alpha = value
@@ -242,6 +243,70 @@ class OverlayPanel(
242 243 }
243 244 }
244 245
246 + /**
247 + * The page's own view, which knows how to be dragged shut.
248 + *
249 + * The launcher cannot do this for us: while the page is open our window
250 + * covers the screen, so the launcher never sees the gesture. A horizontal
251 + * drag here closes the page and is reported as it goes, which is what
252 + * moves the launcher's workspace back into view underneath.
253 + *
254 + * Vertical drags are left alone -- the board scrolls.
255 + */
256 + private inner class PanelLayout(context: Context) : FrameLayout(context) {
257 +
258 + private val slop = ViewConfiguration.get(context).scaledTouchSlop
259 + private var downX = 0f
260 + private var downY = 0f
261 + private var dragging = false
262 +
263 + override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
264 + when (event.actionMasked) {
265 + MotionEvent.ACTION_DOWN -> {
266 + downX = event.x
267 + downY = event.y
268 + dragging = false
269 + }
270 +
271 + MotionEvent.ACTION_MOVE -> {
272 + val dx = event.x - downX
273 + val dy = event.y - downY
274 + // Sideways, and more sideways than up: anything else is
275 + // the list scrolling and is none of our business.
276 + if (!dragging && abs(dx) > slop && abs(dx) > abs(dy)) {
277 + dragging = true
278 + return true
279 + }
280 + }
281 + }
282 + return false
283 + }
284 +
285 + override fun onTouchEvent(event: MotionEvent): Boolean {
286 + when (event.actionMasked) {
287 + MotionEvent.ACTION_DOWN -> {
288 + downX = event.x
289 + return true
290 + }
291 +
292 + MotionEvent.ACTION_MOVE -> {
293 + val dx = event.x - downX
294 + // Dragging left closes; dragging right past open does
295 + // nothing, since there is nothing further to open onto.
296 + apply((1f + dx / width.coerceAtLeast(1)).coerceIn(0f, 1f), report = true)
297 + return true
298 + }
299 +
300 + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
301 + dragging = false
302 + settle()
303 + return true
304 + }
305 + }
306 + return dragging
307 + }
308 + }
309 +
245 310 companion object {
246 311 private const val TAG = "OverlayPanel"
247 312 private const val SETTLE_MS = 200L

Parents: 86553d7