Skip to content

Make the Note, Recording and transcription screens smooth - #15

Merged
silviot merged 9 commits into
mainfrom
smooth-ui
Oct 16, 2026
Merged

silviot merged 9 commits into
mainfrom
smooth-ui

Conversation

@silviot

@silviot silviot commented Oct 16, 2026

Copy link
Copy Markdown
Member

Perfetto traces on a Pixel 8 (120 Hz, minified release build) showed:

  • Note screen: each transcript word was a LinkAnnotation.Clickable, so every paragraph had one child node per word with its own clip path. Scrolling a long note spent most main-thread time in LinksComposables / LinksTextMeasurePolicy; each playing-word change relaid out the paragraph and rebuilt all its links; the frame after the open morph built them all at once. The per-word Paths also fed NativeAlloc GCs that coincided with 100–300 ms RenderThread stalls.
  • Open morph: the note's layer faded with alpha < 1 under CompositingStrategy.Auto, which rendered the whole screen through a full-size offscreen buffer (≈100 ms on the first frame).
  • Recording: the waveform redrew only when a level sample arrived (~10 fps), on an irregular 100/100/100/200 ms rhythm (100 ms samples, 125 ms bars).
  • Transcribing: the progress bar moved only when a ~10 s decode window completed.

Changes:

  • Words are drawn without links: one tap handler per paragraph maps the position to a word; the active and pressed words are drawn in the draw phase, so a new playing word does not relayout the text. LocalDeferWordLinks is gone.
  • The morph fades with CompositingStrategy.ModulateAlpha.
  • The waveform scrolls on the frame clock from timestamped level samples, reading frame state only in the draw phase; static under pause, reduced motion, previews and tests.
  • The progress bar extrapolates between window completions from a pace anchor and glides in the draw phase; never passes the current window end, never goes backwards.
  • Docs and the device accessibility test drop the inline word-link exception. TalkBack no longer gets a per-word link; the "Play from start of turn" action stays.

Goldens are unchanged. Local: detekt, ciTest, lint (:app debug + release, :data, :ui), verbatim, links, resource collisions, and :app:assembleDebugAndroidTest pass.

🤖 Generated with Claude Code

silviot and others added 9 commits October 16, 2026 19:38
The note's graphicsLayer faded with alpha < 1 under CompositingStrategy.Auto,
so every open and close rendered the whole screen into a full-size offscreen
buffer. Allocating and clearing it held the RenderThread for about 100 ms on
the first frame on a Pixel 8. ModulateAlpha fades each draw instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each word of a turn was a LinkAnnotation.Clickable, so Compose made one
child node per word. On a Pixel 8 this dominated the main thread while a
long note scrolled, the open transition (35-125 ms frames) and the switch
to the finished transcript, where the per-word clip paths stalled the
RenderThread for 290 ms. A new active word also added a span, so every
word change laid out the paragraph again.

The paragraph now keeps one AnnotatedString per words, mode, unsettled
range, matches and colours. One tap detector maps the tap position to a
word through the text layout; a tap on a space goes to the nearer word on
the line. The pressed and the active word are drawn from the layout in
the draw phase, and the active word's glyphs are drawn again in
onAccentFill inside its rectangles. The rectangles are cached per layout
and word. An effect reports the active line when the word changes,
because the text no longer lays out again.

LocalDeferWordLinks and its provider go, as there are no links to defer.
TalkBack loses the per-word action; the "Play from start of turn" action
stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The word links used clickable, which in a scrolling list shows the press
only after the tap timeout. The tap detector set the pressed word at once,
so every scroll that started on a word flashed its accentTint background.
The pressed word now shows after the tap timeout, and a quicker tap shows
it for one frame.

The pressed word is now cleared in a finally block. Before, a press that
the detector stopped (new words, or a switch to Draft) left the word
highlighted against the new layout.

ui.md section 6 and its accessibility mapping now describe the tap
detector and the words drawn from the layout instead of the word links.
The paragraph's known gaps also name features.md section 14.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The waveform redrew only when the levels changed: about ten times a second,
and the 100 ms samples against 125 ms bars shifted it in an uneven
100/100/100/200 ms rhythm, so it stepped instead of scrolling.

Each level emission now carries the captured time of its newest sample in a
new WaveformLevels value of :ui, which also holds the bar count, the bar
width and the sample period that RecordingController uses. While recording,
the Waveform runs a frame loop that moves a playhead from that time plus the
time since the sample arrived, at most two samples ahead and never back. The
loop writes frame state that only the Canvas draw lambda reads, so each vsync
redraws without a recomposition and without allocations in the draw. One
more bar at each edge keeps the scroll free of gaps, and the newest bar grows
to its level over a few frames.

Paused, in previews, with reduced motion and in tests (which stop infinite
animations) the waveform draws the same static picture as before, so the
goldens do not change. The 1 s policy tick of the controller stays as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the animation scale at 0, the running waveform showed the static
picture but still asked for a frame on each vsync for the whole
recording. Now the loop shows the static picture and waits for the scale
factor, a snapshot state, to change before it asks for frames again.

The Compose test of a running waveform only showed that a cancelled loop
draws the static picture, so it now has that name. WaveformMotion is
internal, and new JVM tests give it frame times and samples: the first
frame is the static picture, the bars never move back and stop at the
limit, a new bar keeps the picture in place and keeps the bar that
scrolled out, the newest bar grows to its level, and an earlier
captured time starts again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tracker computed progress only when a decode window (about 26 s of new
audio) completed, so the card's bar sat still for seconds and then jumped.

The tracker now publishes an anchor with the progress: the done fraction and
the time of the last completion, the end of the window being decoded, and the
pace measured by the new ProgressPace in :core (the first window of a run
includes the model load, so it only sets the start). The anchor changes only
with a completion or a new window, so equal ticks stay equal and the Note state
is not rebuilt more often.

The card's LinearTrack draws its fill in a modifier node. It predicts the
fill from the anchor, never past the end of the current window and never
backwards, and glides to each published value instead of jumping. Only the
draw is invalidated, at most once per pixel; while it follows the pace, it
waits for the next pixel instead of asking for every frame. With no change it
draws the given fraction exactly, so the previews and goldens do not change.
With reduced motion it goes to each value at once. The percent and the stats
keep their update rate. TranscriptionProgress, verbatim code, does not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The moving fill was drawn at x = 0, so in an RTL layout the card's bar
filled from the left while the static track (and the Library banner)
filled from the right. The fill now starts at the right edge in RTL. A
new test compares the moving and static tracks in both directions.

The glide decayed the distance to an aim that keeps moving with the
anchor's prediction, so it trailed the aim at about a quarter second of
pace. At any pace above about 6 px/s that lag stayed over the snap
distance and the loop asked for every frame until the window end.
TrackMotion.step now takes the aim of the frame before: the track moves
with the aim and only the gap to it decays, so a glide settles in about
the same time at any pace and the loop goes back to one wake per pixel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The words are no longer LinkAnnotations, so the design docs, the development
notes and the device accessibility test lose the inline word link exception.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The push transition faded the note in over 1 ms. Its first frame drew the
whole screen at alpha 0 through a full-size offscreen buffer, and allocating
and clearing that buffer still held the RenderThread for about 93 ms on a
Pixel 8 after the morph itself stopped using one. noteContainerTransition
already starts the note at alpha 0, so the fadeIn did nothing visible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@silviot
silviot merged commit 0740707 into main Oct 16, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant