Land a Flutter jump on the line it aimed at
`message-row` has marked its row `:scroll-here` since the overview grew a "go to message"; the Flutter backend never read the mark, so a jump opened the room and left the view at the end of it. The row is not a widget that can be found afterwards — the tree is rebuilt from the top whenever a cell fires — but the build that paints it has its BuildContext, so it records it, and the scroll around it reads it back after the frame and ensureVisibles onto it. A third of the way down rather than at the top edge: a line arrived at reads as an answer to the ones above it. The end-ward jump stands down when a jump is pending, because `jump-tick` renames the viewport for both gestures and the end is the wrong one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1528bb0 parent: cc17757 modified
flutter/src/frq/hiccup.cljd +50 -2 | @@ -189,6 +189,17 @@ | ||
| 189 | 189 | ;; showing it, and it has to survive the rebuild that replaces one. |
| 190 | 190 | (defonce ^:private scroll-marks (atom {})) |
| 191 | 191 | |
| 192 | +;; Where a jump is aiming, once the frame that paints it has a context to | |
| 193 | +;; aim at. | |
| 194 | +;; | |
| 195 | +;; `message-row` marks one row `:scroll-here` while a jump is on it, and a | |
| 196 | +;; row is not a widget this backend can find afterwards — the tree is rebuilt | |
| 197 | +;; from the top every time a cell fires, so there is no handle on it that | |
| 198 | +;; outlives the build. The build itself has one: the marked row's own | |
| 199 | +;; BuildContext, recorded here as it is built and read back by the scroll | |
| 200 | +;; around it after the frame. | |
| 201 | +(defonce ^:private jump-context (atom nil)) | |
| 202 | + | |
| 192 | 203 | (defn- end-ward! |
| 193 | 204 | "Put `k` at the end after this frame, when it should be. |
| 194 | 205 | |
| @@ -221,7 +232,12 @@ | ||
| 221 | 232 | (.addPostFrameCallback |
| 222 | 233 | (.-instance m/WidgetsBinding) |
| 223 | 234 | (fn [_] |
| 224 | - (when (.-hasClients ctrl) | |
| 235 | + ;; Not when a jump is pending: being taken to a line and being taken | |
| 236 | + ;; to the end are the same gesture as far as `jump-tick` is | |
| 237 | + ;; concerned — the screen renames the viewport either way — and the | |
| 238 | + ;; end is the wrong one of the two. By now the marked row has been | |
| 239 | + ;; built, so this knows which gesture it was. | |
| 240 | + (when (and (nil? @jump-context) (.-hasClients ctrl)) | |
| 225 | 241 | (.jumpTo ctrl (.-maxScrollExtent (.-position ctrl))))))))) |
| 226 | 242 | |
| 227 | 243 | (defonce ^:private scroll-watch |
| @@ -264,6 +280,29 @@ | ||
| 264 | 280 | (when f (f said))))) |
| 265 | 281 | nil)))))) |
| 266 | 282 | |
| 283 | +(defn- here-ward! | |
| 284 | + "Bring the row a jump is aiming at into view, when one was built. | |
| 285 | + | |
| 286 | + After the frame, for the reason `end-ward!` is: the row is built inside | |
| 287 | + this scroll and so does not exist yet when the scroll is, and a context | |
| 288 | + with no render object behind it cannot be scrolled to. | |
| 289 | + | |
| 290 | + Aimed a third of the way down rather than at the top edge, because a line | |
| 291 | + arrived at reads as an answer to the lines above it, and pinned to the top | |
| 292 | + it has none of them." | |
| 293 | + [] | |
| 294 | + (.addPostFrameCallback | |
| 295 | + (.-instance m/WidgetsBinding) | |
| 296 | + (fn [_] | |
| 297 | + (when-let [^m/BuildContext c @jump-context] | |
| 298 | + ;; Cleared here rather than when the jump does: `jump-to` comes off a | |
| 299 | + ;; frame or two later and this has to have happened by then, and a | |
| 300 | + ;; context kept past the frame that made it is a context to a widget | |
| 301 | + ;; that may be gone. | |
| 302 | + (reset! jump-context nil) | |
| 303 | + (when (.-mounted c) | |
| 304 | + (m/Scrollable.ensureVisible c .alignment 0.3)))))) | |
| 305 | + | |
| 267 | 306 | (defn- fills-column? |
| 268 | 307 | "Whether a node takes the height its column has left over. |
| 269 | 308 | |
| @@ -417,6 +456,10 @@ | ||
| 417 | 456 | (f/widget |
| 418 | 457 | :context ctx |
| 419 | 458 | (let [p (props node) |
| 459 | + ;; The row a jump is aiming at, saying so as it is built. Here and | |
| 460 | + ;; not in an arm of the `case` below, because the mark is a property | |
| 461 | + ;; any tag may carry — it happens to be a `:vbox` today. | |
| 462 | + _ (when (:scroll-here p) (reset! jump-context ctx)) | |
| 420 | 463 | kids (children (body node)) |
| 421 | 464 | ;; One column builder for every container, so the rule about what |
| 422 | 465 | ;; fills is applied in one place: children that take what is left are |
| @@ -785,8 +828,13 @@ | ||
| 785 | 828 | ;; "ScrollController attached to multiple scroll views", which took |
| 786 | 829 | ;; the whole screen. |
| 787 | 830 | :managed [ctrl (m/ScrollController)] |
| 831 | + ;; `here-ward!` after `end-ward!`, so the callback that aims at a | |
| 832 | + ;; line is queued behind the one that aims at the end: the end's | |
| 833 | + ;; runs first, sees the pending jump and stands down, and then this | |
| 834 | + ;; one clears it. | |
| 788 | 835 | :let [_ (watch-end! k ctrl on-change) |
| 789 | - _ (end-ward! k ctrl token)] | |
| 836 | + _ (end-ward! k ctrl token) | |
| 837 | + _ (here-ward!)] | |
| 790 | 838 | (m/SingleChildScrollView |
| 791 | 839 | .controller ctrl |
| 792 | 840 | .child (col (dbl (:spacing p) 0.0) (body node))))) |
| @@ -189,6 +189,17 @@ | |||
| 189 | ;; showing it, and it has to survive the rebuild that replaces one. | 189 | ;; showing it, and it has to survive the rebuild that replaces one. |
| 190 | (defonce ^:private scroll-marks (atom {})) | 190 | (defonce ^:private scroll-marks (atom {})) |
| 191 | 191 | ||
| 192 | +;; Where a jump is aiming, once the frame that paints it has a context to | ||
| 193 | +;; aim at. | ||
| 194 | +;; | ||
| 195 | +;; `message-row` marks one row `:scroll-here` while a jump is on it, and a | ||
| 196 | +;; row is not a widget this backend can find afterwards — the tree is rebuilt | ||
| 197 | +;; from the top every time a cell fires, so there is no handle on it that | ||
| 198 | +;; outlives the build. The build itself has one: the marked row's own | ||
| 199 | +;; BuildContext, recorded here as it is built and read back by the scroll | ||
| 200 | +;; around it after the frame. | ||
| 201 | +(defonce ^:private jump-context (atom nil)) | ||
| 202 | + | ||
| 192 | (defn- end-ward! | 203 | (defn- end-ward! |
| 193 | "Put `k` at the end after this frame, when it should be. | 204 | "Put `k` at the end after this frame, when it should be. |
| 194 | 205 | ||
| @@ -221,7 +232,12 @@ | |||
| 221 | (.addPostFrameCallback | 232 | (.addPostFrameCallback |
| 222 | (.-instance m/WidgetsBinding) | 233 | (.-instance m/WidgetsBinding) |
| 223 | (fn [_] | 234 | (fn [_] |
| 224 | - (when (.-hasClients ctrl) | 235 | + ;; Not when a jump is pending: being taken to a line and being taken |
| 236 | + ;; to the end are the same gesture as far as `jump-tick` is | ||
| 237 | + ;; concerned — the screen renames the viewport either way — and the | ||
| 238 | + ;; end is the wrong one of the two. By now the marked row has been | ||
| 239 | + ;; built, so this knows which gesture it was. | ||
| 240 | + (when (and (nil? @jump-context) (.-hasClients ctrl)) | ||
| 225 | (.jumpTo ctrl (.-maxScrollExtent (.-position ctrl))))))))) | 241 | (.jumpTo ctrl (.-maxScrollExtent (.-position ctrl))))))))) |
| 226 | 242 | ||
| 227 | (defonce ^:private scroll-watch | 243 | (defonce ^:private scroll-watch |
| @@ -264,6 +280,29 @@ | |||
| 264 | (when f (f said))))) | 280 | (when f (f said))))) |
| 265 | nil)))))) | 281 | nil)))))) |
| 266 | 282 | ||
| 283 | +(defn- here-ward! | ||
| 284 | + "Bring the row a jump is aiming at into view, when one was built. | ||
| 285 | + | ||
| 286 | + After the frame, for the reason `end-ward!` is: the row is built inside | ||
| 287 | + this scroll and so does not exist yet when the scroll is, and a context | ||
| 288 | + with no render object behind it cannot be scrolled to. | ||
| 289 | + | ||
| 290 | + Aimed a third of the way down rather than at the top edge, because a line | ||
| 291 | + arrived at reads as an answer to the lines above it, and pinned to the top | ||
| 292 | + it has none of them." | ||
| 293 | + [] | ||
| 294 | + (.addPostFrameCallback | ||
| 295 | + (.-instance m/WidgetsBinding) | ||
| 296 | + (fn [_] | ||
| 297 | + (when-let [^m/BuildContext c @jump-context] | ||
| 298 | + ;; Cleared here rather than when the jump does: `jump-to` comes off a | ||
| 299 | + ;; frame or two later and this has to have happened by then, and a | ||
| 300 | + ;; context kept past the frame that made it is a context to a widget | ||
| 301 | + ;; that may be gone. | ||
| 302 | + (reset! jump-context nil) | ||
| 303 | + (when (.-mounted c) | ||
| 304 | + (m/Scrollable.ensureVisible c .alignment 0.3)))))) | ||
| 305 | + | ||
| 267 | (defn- fills-column? | 306 | (defn- fills-column? |
| 268 | "Whether a node takes the height its column has left over. | 307 | "Whether a node takes the height its column has left over. |
| 269 | 308 | ||
| @@ -417,6 +456,10 @@ | |||
| 417 | (f/widget | 456 | (f/widget |
| 418 | :context ctx | 457 | :context ctx |
| 419 | (let [p (props node) | 458 | (let [p (props node) |
| 459 | + ;; The row a jump is aiming at, saying so as it is built. Here and | ||
| 460 | + ;; not in an arm of the `case` below, because the mark is a property | ||
| 461 | + ;; any tag may carry — it happens to be a `:vbox` today. | ||
| 462 | + _ (when (:scroll-here p) (reset! jump-context ctx)) | ||
| 420 | kids (children (body node)) | 463 | kids (children (body node)) |
| 421 | ;; One column builder for every container, so the rule about what | 464 | ;; One column builder for every container, so the rule about what |
| 422 | ;; fills is applied in one place: children that take what is left are | 465 | ;; fills is applied in one place: children that take what is left are |
| @@ -785,8 +828,13 @@ | |||
| 785 | ;; "ScrollController attached to multiple scroll views", which took | 828 | ;; "ScrollController attached to multiple scroll views", which took |
| 786 | ;; the whole screen. | 829 | ;; the whole screen. |
| 787 | :managed [ctrl (m/ScrollController)] | 830 | :managed [ctrl (m/ScrollController)] |
| 831 | + ;; `here-ward!` after `end-ward!`, so the callback that aims at a | ||
| 832 | + ;; line is queued behind the one that aims at the end: the end's | ||
| 833 | + ;; runs first, sees the pending jump and stands down, and then this | ||
| 834 | + ;; one clears it. | ||
| 788 | :let [_ (watch-end! k ctrl on-change) | 835 | :let [_ (watch-end! k ctrl on-change) |
| 789 | - _ (end-ward! k ctrl token)] | 836 | + _ (end-ward! k ctrl token) |
| 837 | + _ (here-ward!)] | ||
| 790 | (m/SingleChildScrollView | 838 | (m/SingleChildScrollView |
| 791 | .controller ctrl | 839 | .controller ctrl |
| 792 | .child (col (dbl (:spacing p) 0.0) (body node))))) | 840 | .child (col (dbl (:spacing p) 0.0) (body node))))) |