Repository navigation
Add Design Doc for Composable Text Plugins in Flutter - #35
hannah-hyj wants to merge 7 commits into
Conversation
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
justinmc
left a comment
There was a problem hiding this comment.
Quick drive by review until I can take a deeper look:
The demo is awesome. Can you share the source code? I think the repo (https://github.com/hannah-hyj/flutter_text_plugin_demo) contains only the compiled assets.
One thing we should be wary of in this design is complexity that increases with the amount of text on the page. Flutter is currently already pretty bad at handling large amounts of text, but we should avoid making that worse if we can.
|
|
||
| When a user starts in the middle of a `ListView.builder` (such as `Item 10`) and scrolls upward, `RenderSliverList` mounts `Item 9`, then `Item 8`, then `Item 7`. As a result, `didAddText` fires in the order `[Item 10, Item 9, Item 8, Item 7]`. If `SearchInPagePlugin` iterates over `_delegates` in registration order, pressing "Next Match" jumps backward up the document. | ||
|
|
||
| Comparing global screen coordinates (`localToGlobal(Offset.zero).dy`) also fails for general layouts. Two side-by-side columns or wrapped flex items can have overlapping vertical coordinates even though they have a well-defined reading order in the widget tree. |
There was a problem hiding this comment.
SelectableRegion has this same problem, we've worked around this in the text case by ordering by topLeft, but this likely is not robust enough for all text use-cases. Would be interested in seeing if this same LCA ordering can be used for SelectableRegion and if there's anything that can be shared here.
|
|
||
| Domain-specific or third-party-dependent features belong in standalone community packages: | ||
|
|
||
| 1. `SpellCheckPlugin` (`package:flutter_spellcheck`) renders IME spellcheck squiggly underlines and grammar suggestions. |
There was a problem hiding this comment.
I'd argue this may be a good candidate for a first-party plugin since spell check is a default feature we currently provide. That decoration is baked into the InlineSpan itself but it does surface the question about what default capabilities should use the plugin system or just directly modify the InlineSpan for decorations.
There was a problem hiding this comment.
Do you think this should be in flutter/flutter or flutter/package? that is - is this a OS behavior or design library behavior
There was a problem hiding this comment.
I agree it is a good candidate. On flutter/flutter versus flutter/packages I do not have an answer yet, and I listed it as open.
|
|
||
| ##### 2. Per-widget `Text(selectionColor: ...)` and inline `WidgetSpan`s | ||
|
|
||
| A single `_SelectionHighlightTextPlugin` installed at the root `SelectableRegion` only knows the default selection color from `DefaultSelectionStyle`. If a descendant `Text('Warning', selectionColor: Colors.red)` overrides its selection color, or if a `Text.rich` contains inline `WidgetSpan`s that split the paragraph into multiple selectable ranges, the plugin cannot render accurately unless `TextDelegate` exposes per-paragraph `selectionColor` and `selections` (where `getBoxesForSelection` defaults to `includePlaceholders: false` so inline widgets are not highlighted). |
There was a problem hiding this comment.
Good callout, I wonder if we can have a OverrideSelectionStyleTextPlugin or similar that a user can wrap around a subtree to handle this use-case. Probably non-blocking for initial implementation but we should consider it if we do plan to migrate selectableregion to use the plugin. Personally I lean towards that migration since it further decouples selection from text.
justinmc
left a comment
There was a problem hiding this comment.
There are probably lots of little details about whether we want to expose certain properties on some of the core classes, etc. but overall I think this looks really good for such an ambitious project. The demo is really great proof of this idea.
For the record I was reviewing the code and example code here: flutter/flutter@master...hannah-hyj:flutter:text-plugin-testing
| } | ||
| ``` | ||
|
|
||
| #### Post-layout lifecycle guarantee (`didAddText`, `didUpdateText`, `didLayoutText`) |
There was a problem hiding this comment.
I mentioned this elsewhere, but I wonder about the performance of how this is done. Ideally we do this in a way that scales with the amount of text on the page.
|
|
||
| #### Post-layout lifecycle guarantee (`didAddText`, `didUpdateText`, `didLayoutText`) | ||
|
|
||
| A key lesson from prototyping is that invoking `didAddText` during `attach()` / `updateRenderObject()` (before layout) or invoking `didUpdateText` inside `set text` (immediately after `markNeedsLayout()`) or invoking `didLayoutText` inside `performLayout()` (before `RenderObject.layout()` clears `_needsLayout = false` and before parent `RenderBox`es position child offsets) creates severe traps for plugin authors: |
There was a problem hiding this comment.
severe traps
I wish we had a clear "text layout" phase, and then we could run a secondary layout after that similar to LayoutBuilder. That way it would be very clear when text metrics are available and not.
This is probably beyond the scope of this RFC though. I think the proposal below is reasonable.
| | **Mobile drag handle positioning (`LeaderLayer`)** | **No (not via `CustomPainter`)** | Requires `PaintingContext.pushLayer(LeaderLayer(...))` and `alwaysNeedsCompositing` on `RenderParagraph`. | | ||
| | **Gesture dispatch and cross-widget selection** | **No** | Handled by `SelectableRegion` and `SelectionRegistrar` across both text and non-text `Selectable` render objects. | | ||
|
|
||
| #### 5.2.4 Follow-up roadmap: what it would take to migrate `SelectionArea` to `TextPlugin` |
There was a problem hiding this comment.
I wonder what @chunhtai thinks of this.
|
|
||
| Domain-specific or third-party-dependent features belong in standalone community packages: | ||
|
|
||
| 1. `SpellCheckPlugin` (`package:flutter_spellcheck`) renders IME spellcheck squiggly underlines and grammar suggestions. |
There was a problem hiding this comment.
Do you think this should be in flutter/flutter or flutter/package? that is - is this a OS behavior or design library behavior
|
|
||
| 1. **`didAddText(delegate)`** fires once at the end of `flushLayout()` after a newly attached `(RenderObject, TextPlugin)` pair completes its initial layout. | ||
| 2. **`didUpdateText(delegate)`** fires at the end of `flushLayout()` when an already-added delegate's text (or text style) changed since the last layout pass. | ||
| 3. **`didLayoutText(delegate)`** fires at the end of `flushLayout()` when an already-added delegate re-laid out (for example, due to viewport constraint changes) without its text changing. Within a single frame, a delegate receives at most one of `didAddText`, `didUpdateText`, or `didLayoutText`. |
There was a problem hiding this comment.
at most one of the did* will be called during a flushLayout? The name didLayoutText feels like will be called whenever the text is laid out accompany a didAddText and didUpdateText. maybe rename it like didRelayoutText
However, do you think we need to tell between didUpdateText and didLayoutText, or can they be a single didUpdate?
|
|
||
| To eliminate these traps, `RenderParagraph` and `RenderEditable` dispatch `didAddText`, `didUpdateText`, and `didLayoutText` at the **post-layout phase** at the end of `PipelineOwner.flushLayout()` (via `PipelineOwner.schedulePostLayoutCallback`), immediately after all dirty `RenderObject`s have finished layout (`_needsLayout == false` and parent `offset`s are finalized) and **before** `PipelineOwner.flushPaint()` runs: | ||
|
|
||
| 1. **`didAddText(delegate)`** fires once at the end of `flushLayout()` after a newly attached `(RenderObject, TextPlugin)` pair completes its initial layout. |
There was a problem hiding this comment.
is TextDelegate a read only interface? we need to make sure that is the case if possible in case people wrote plugin that mutate the provided TextDelegate.
|
|
||
| `TextPluginScope` uses an internal `InheritedWidget` (`_InheritedTextPluginScope`) that resolves, deduplicates, and orders active plugins down the widget tree during `build`: | ||
|
|
||
| 1. **`Type`-based deduplication and inner-scope override**: Like Flutter's `Actions` and `Shortcuts` widgets, `TextPluginScope` deduplicates plugins by their concrete `runtimeType` (`Map<Type, TextPlugin>`). When an inner `TextPluginScope` provides a plugin of type `T`, it overrides any ancestor plugin of the same type `T` for its descendant subtree. This matches `TextPluginScope.exclude(types: {T})` and guarantees that at most one instance of any given `TextPlugin` subclass is active on a `RenderParagraph` or `RenderEditable`. |
There was a problem hiding this comment.
better if it can be factory to create TextPlugin instead of an TextPlugin object directly.
There was a problem hiding this comment.
that way if TextPlugin need to be dispose, it will be the inheritedwidget's job to do it. and less of a footgun for the developer who create it on the flight
| set caseSensitive(bool value); | ||
|
|
||
| bool get eagerLoadOffscreenText => _eagerLoadOffscreenText; | ||
| set eagerLoadOffscreenText(bool value); |
There was a problem hiding this comment.
who gets ownership for this object? if it is mutable the developer will need a statefulWidget to keep a reference to SearchInPagePlugin and pass it into the TextPluginScope.
bascially are you imagine the usage to look like
MyState extends State {
late SearchInPagePlugin plugin = SearchInPagePlugin();
@override
void didUpdateWidget(...) {
plugin.eagerLoadOffscreenText = widget.someFlag;
}
@override
void dispose(...) {
plugin.dispose();
}
@override
void build(..) {
return TextPluginScope(
plugins: {SearchInPagePlugin: plugin}
)
}
}or
MyWidget extends StatelessWidget {
void _createSearchPlugin() {
return SearchInPagePlugin(eagerLoadOffscreenText: someFlag);
}
@override
void build(..) {
return TextPluginScope(
plugins: {SearchInPagePlugin: _createSearchPlugin}
)
}
}I can see issue with both though.
if using state, the developer needs to handle its lifecycle and waste of resource if it gets override in the latter plugin scope.
If using factory, we need a way to let TextPluginScope to know when to recreate the plugin even if the factory instance is the same.
| To solve all four issues: | ||
|
|
||
| 1. `TextDelegate` tracks a dedicated `_hasValidLayout` boolean (set to `false` in `markNeedsLayout()` and `true` at the end of `performLayout()`), so `delegate.hasLayout` is accurate in both debug and release builds. | ||
| 2. `RenderParagraph` and `RenderEditable` schedule `didAddText`, `didUpdateText`, and `didLayoutText` via `PipelineOwner.schedulePostLayoutCallback`, which flushes at the end of `PipelineOwner.flushLayout()` after all dirty `RenderObject`s have exited `layout()` and finalized parent offsets, and right before `PipelineOwner.flushPaint()`. |
There was a problem hiding this comment.
Are you allowed to invalidate layout or build widgets during PipelineOwner.schedulePostLayoutCallback? (I suspect the answer should be no, and if possible PipelineOwner.schedulePostLayoutCallback should strictly enforce this with assertions)
| To solve all four issues: | ||
|
|
||
| 1. `TextDelegate` tracks a dedicated `_hasValidLayout` boolean (set to `false` in `markNeedsLayout()` and `true` at the end of `performLayout()`), so `delegate.hasLayout` is accurate in both debug and release builds. | ||
| 2. `RenderParagraph` and `RenderEditable` schedule `didAddText`, `didUpdateText`, and `didLayoutText` via `PipelineOwner.schedulePostLayoutCallback`, which flushes at the end of `PipelineOwner.flushLayout()` after all dirty `RenderObject`s have exited `layout()` and finalized parent offsets, and right before `PipelineOwner.flushPaint()`. |
There was a problem hiding this comment.
I assume the post-layout callback is called after the PipelineOwner has also flushed its children PipelineOwners layout? That might be worth stating since that has implications on what layout information is available to you when the post-layout callback is invoked.
There was a problem hiding this comment.
A potential alternative to might be to introduce a RendererBinding.addPostLayoutCallback method (I changed schedule->add to better mirror RendererBinding.addPostFrameCallback).
RendererBinding.drawFrame() would call this after flushing the root pipeline owner's layout. The benefit of this approach is that it would happen once per frame, instead of once per frame for each render tree.
|
|
||
| A single `_SelectionHighlightTextPlugin` installed at the root `SelectableRegion` only knows the default selection color from `DefaultSelectionStyle`. If a descendant `Text('Warning', selectionColor: Colors.red)` overrides its selection color, or if a `Text.rich` contains inline `WidgetSpan`s that split the paragraph into multiple selectable ranges, the plugin cannot render accurately unless `TextDelegate` exposes per-paragraph `selectionColor` and `selections` (where `getBoxesForSelection` defaults to `includePlaceholders: false` so inline widgets are not highlighted). | ||
|
|
||
| ##### 3. Plugin opt-out (`TextPluginScope.none` versus `TextPluginScope.exclude`) |
There was a problem hiding this comment.
For things like stacked navigation of pages or tabbed content - where widgets are mounted but not visible - will we automatically opt them out of plugins?
|
|
||
| Because `didAddText`, `didUpdateText`, and `didLayoutText` run at the end of `PipelineOwner.flushLayout()`, `delegate.hasLayout` is already `true` whenever a delegate is added or updated: | ||
|
|
||
| 1. Calling `scrollToActiveMatch()` from `nextMatch()` or `previousMatch()` can immediately invoke `current.delegate.ensureVisible(current.range, duration: duration, curve: curve)` because every delegate in `_matches` has already completed layout. |
There was a problem hiding this comment.
If the match is in a multi-line text field but the match is not currently visible, you need to scroll the text field itself to make the match visible - will that work? Is that something the TextDelegate (in this case RenderEditable) is responsible for figuring out?
|
|
||
| `SearchInPagePlugin` is the primary first-party `TextPlugin` motivating this proposal, providing drop-in `Ctrl+F` / `Cmd+F` Find-in-Page across any subtree of `Text`, `RichText`, and `EditableText` widgets. | ||
|
|
||
| #### 3.6.1 Public API and `ChangeNotifier` integration |
There was a problem hiding this comment.
People might want to heavily customize the search UI. Will we allow that customization? Also I imagine the default search UI depends on Material/Cupertino, will that logic need to live in flutter/packages? What will the API boundary be between the core framework and the design system packages?
Stage 1 (painter only) is the proposal. Stages 2 and 3 are described as future stages with what each experiment showed. Removes the pointer event API, corrects places where the text disagreed with the prototype, drops the mermaid diagrams, and adds sections on plugin ownership, performance, the limits of stage 1, open questions and live demos.

demos:
Stage 1 (painter only): https://text-plugin-mvp-demo.web.app/
Stage 2 (annotations): https://text-plugin-annotations-demo.web.app/
Stage 3 (widgets): https://text-plugin-widget-demo.web.app/
Prototype branches link:
Stage 1 (painter only): https://github.com/hannah-hyj/flutter/tree/text-plugin-mvp
Stage 2 (annotations): https://github.com/hannah-hyj/flutter/tree/text-plugin-annotations
Stage 3 (widgets): https://github.com/hannah-hyj/flutter/tree/text-plugin-with-widget
If you want to access demo app code , it's in {branch}/examples/text_plugins
issue: flutter/flutter#65504
#25 was closed in favor of this one
Summary
Proposes a composable text plugin architecture (
TextPluginScope,TextPlugin, andTextDelegate) inpackage:flutter/rendering.dartandpackage:flutter/widgets.dart.This enables cross-cutting text inspection, background/foreground decoration painting, pointer gesture handling, and scroll-to-range across standard
Text,RichText, andEditableText(TextField) subtrees (RenderParagraphandRenderEditable) without requiring custom text widgets orTextSpanwrappers.Key Use Cases
Ctrl+F) across lazy scrollables (ListView.builder) and text fieldsPre-launch Checklist
design doc.dart run bin/assign_rfc_number.dartlocally when instructed).If you need help, consider asking for advice on the #hackers channel on Discord.