Skip to content

Commit 652f8ee

Browse files
[SuperEditor][SuperTextField] Fix text layout invalidating when selection changes (Resolves #2611) (#2614)
1 parent 25db622 commit 652f8ee

File tree

5 files changed

+232
-40
lines changed

5 files changed

+232
-40
lines changed

super_editor/lib/src/infrastructure/attributed_text_styles.dart

+44-1
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ extension ComputeTextSpan on AttributedText {
5555

5656
if (inlineWidget != null) {
5757
inlineSpans.add(
58-
WidgetSpan(
58+
_LayoutOptimizedWidgetSpan(
5959
alignment: PlaceholderAlignment.middle,
6060
child: inlineWidget,
6161
),
@@ -154,3 +154,46 @@ typedef InlineWidgetBuilder = Widget? Function(
154154
TextStyle textStyle,
155155
Object placeholder,
156156
);
157+
158+
/// A [WidgetSpan] that does not re-layout its child changed.
159+
///
160+
/// The [WidgetSpan] class always invalidates its layout when the child
161+
/// widget changes. However, this shouldn't happen, since invalidating
162+
/// the layout should happen at `RenderObject` level.
163+
///
164+
/// When the child widget do change its layout, i.e., by changing its size,
165+
/// the build pipeline will already mark the layout as dirty.
166+
class _LayoutOptimizedWidgetSpan extends WidgetSpan {
167+
const _LayoutOptimizedWidgetSpan({
168+
required Widget child,
169+
required PlaceholderAlignment alignment,
170+
}) : super(child: child, alignment: alignment);
171+
172+
@override
173+
RenderComparison compareTo(InlineSpan other) {
174+
if (identical(this, other)) {
175+
return RenderComparison.identical;
176+
}
177+
if (other.runtimeType != runtimeType) {
178+
return RenderComparison.layout;
179+
}
180+
if ((style == null) != (other.style == null)) {
181+
return RenderComparison.layout;
182+
}
183+
final typedOther = other as WidgetSpan;
184+
if (alignment != typedOther.alignment) {
185+
return RenderComparison.layout;
186+
}
187+
RenderComparison result = RenderComparison.identical;
188+
if (style != null) {
189+
final candidate = style!.compareTo(other.style!);
190+
if (candidate.index > result.index) {
191+
result = candidate;
192+
}
193+
if (result == RenderComparison.layout) {
194+
return result;
195+
}
196+
}
197+
return result;
198+
}
199+
}

super_editor/lib/src/super_textfield/android/android_textfield.dart

+11-16
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import 'package:attributed_text/attributed_text.dart';
21
import 'package:flutter/material.dart';
32
import 'package:flutter/services.dart';
43
import 'package:follow_the_leader/follow_the_leader.dart';
@@ -573,14 +572,6 @@ class SuperAndroidTextFieldState extends State<SuperAndroidTextField>
573572

574573
@override
575574
Widget build(BuildContext context) {
576-
return OverlayPortal(
577-
controller: _popoverController,
578-
overlayChildBuilder: _buildPopoverToolbar,
579-
child: _buildTextField(),
580-
);
581-
}
582-
583-
Widget _buildTextField() {
584575
return TapRegion(
585576
groupId: widget.tapRegionGroupId,
586577
child: Focus(
@@ -688,13 +679,17 @@ class SuperAndroidTextFieldState extends State<SuperAndroidTextField>
688679
return const SizedBox();
689680
}
690681

691-
return TextLayoutCaret(
692-
textLayout: textLayout,
693-
style: widget.caretStyle,
694-
position: _textEditingController.selection.isCollapsed //
695-
? _textEditingController.selection.extent
696-
: null,
697-
blinkController: _caretBlinkController,
682+
return OverlayPortal(
683+
controller: _popoverController,
684+
overlayChildBuilder: _buildPopoverToolbar,
685+
child: TextLayoutCaret(
686+
textLayout: textLayout,
687+
style: widget.caretStyle,
688+
position: _textEditingController.selection.isCollapsed //
689+
? _textEditingController.selection.extent
690+
: null,
691+
blinkController: _caretBlinkController,
692+
),
698693
);
699694
},
700695
),

super_editor/lib/src/super_textfield/ios/ios_textfield.dart

+19-23
Original file line numberDiff line numberDiff line change
@@ -573,14 +573,6 @@ class SuperIOSTextFieldState extends State<SuperIOSTextField>
573573

574574
@override
575575
Widget build(BuildContext context) {
576-
return OverlayPortal(
577-
controller: _popoverController,
578-
overlayChildBuilder: _buildOverlayIosControls,
579-
child: _buildTextField(),
580-
);
581-
}
582-
583-
Widget _buildTextField() {
584576
return TapRegion(
585577
groupId: widget.tapRegionGroupId,
586578
child: Focus(
@@ -694,21 +686,25 @@ class SuperIOSTextFieldState extends State<SuperIOSTextField>
694686
return const SizedBox();
695687
}
696688

697-
return Stack(
698-
clipBehavior: Clip.none,
699-
children: [
700-
TextLayoutCaret(
701-
textLayout: textLayout,
702-
style: widget.caretStyle,
703-
position: _textEditingController.selection.isCollapsed //
704-
? _textEditingController.selection.extent
705-
: null,
706-
blinkController: _caretBlinkController,
707-
),
708-
IOSFloatingCursor(
709-
controller: _floatingCursorController,
710-
),
711-
],
689+
return OverlayPortal(
690+
controller: _popoverController,
691+
overlayChildBuilder: _buildOverlayIosControls,
692+
child: Stack(
693+
clipBehavior: Clip.none,
694+
children: [
695+
TextLayoutCaret(
696+
textLayout: textLayout,
697+
style: widget.caretStyle,
698+
position: _textEditingController.selection.isCollapsed //
699+
? _textEditingController.selection.extent
700+
: null,
701+
blinkController: _caretBlinkController,
702+
),
703+
IOSFloatingCursor(
704+
controller: _floatingCursorController,
705+
),
706+
],
707+
),
712708
);
713709
},
714710
),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter_test/flutter_test.dart';
3+
import 'package:flutter_test_runners/flutter_test_runners.dart';
4+
import 'package:super_editor/super_editor.dart';
5+
import 'package:super_editor/super_editor_test.dart';
6+
import 'package:super_text_layout/super_text_layout.dart';
7+
8+
import 'supereditor_test_tools.dart';
9+
10+
void main() {
11+
group('SuperEditor > inline widgets >', () {
12+
testWidgetsOnAllPlatforms('does not invalidate layout when selection changes', (tester) async {
13+
await tester
14+
.createDocument()
15+
.withCustomContent(
16+
MutableDocument(
17+
nodes: [
18+
ParagraphNode(
19+
id: '1',
20+
text: AttributedText('Hello, world!', null, {
21+
7: const _NamedPlaceHolder('world'),
22+
}),
23+
),
24+
],
25+
),
26+
)
27+
.useStylesheet(
28+
defaultStylesheet.copyWith(
29+
inlineWidgetBuilders: [_boxPlaceHolderBuilder],
30+
),
31+
)
32+
.pump();
33+
34+
// Place the caret at the beginning of the paragraph.
35+
await tester.placeCaretInParagraph('1', 0);
36+
37+
// Keep track of whether of not the layout was invalidated.
38+
bool wasLayoutInvalidated = false;
39+
40+
final renderParagraph = find
41+
.byType(LayoutAwareRichText) //
42+
.evaluate()
43+
.first
44+
.findRenderObject() as RenderLayoutAwareParagraph;
45+
renderParagraph.onMarkNeedsLayout = () {
46+
wasLayoutInvalidated = true;
47+
};
48+
49+
// Place the selection somewhere else.
50+
await tester.placeCaretInParagraph('1', 2);
51+
52+
// Ensure the layout was not invalidated.
53+
expect(wasLayoutInvalidated, isFalse);
54+
});
55+
});
56+
}
57+
58+
/// A builder that renders a [ColoredBox] for a [_NamedPlaceHolder].
59+
Widget? _boxPlaceHolderBuilder(BuildContext context, TextStyle textStyle, Object placeholder) {
60+
if (placeholder is! _NamedPlaceHolder) {
61+
return null;
62+
}
63+
64+
return KeyedSubtree(
65+
key: ValueKey('placeholder-${placeholder.name}'),
66+
child: LineHeight(
67+
style: textStyle,
68+
child: const SizedBox(
69+
width: 24,
70+
child: ColoredBox(
71+
color: Colors.yellow,
72+
),
73+
),
74+
),
75+
);
76+
}
77+
78+
/// A placeholder that is identified by a name.
79+
class _NamedPlaceHolder {
80+
const _NamedPlaceHolder(this.name);
81+
82+
final String name;
83+
84+
@override
85+
bool operator ==(Object other) =>
86+
identical(this, other) || other is _NamedPlaceHolder && runtimeType == other.runtimeType && name == other.name;
87+
88+
@override
89+
int get hashCode => name.hashCode;
90+
}

super_editor/test/super_textfield/super_textfield_inline_widgets_test.dart

+68
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
44
import 'package:flutter_test_robots/flutter_test_robots.dart';
55
import 'package:flutter_test_runners/flutter_test_runners.dart';
66
import 'package:super_editor/super_editor.dart';
7+
import 'package:super_text_layout/super_text_layout.dart';
78

89
import 'super_textfield_inspector.dart';
910
import 'super_textfield_robot.dart';
@@ -318,6 +319,73 @@ void main() {
318319
'before after',
319320
);
320321
});
322+
323+
testWidgetsOnAllPlatforms('selects inline widget upon double tap', (tester) async {
324+
// This test ensures that SuperTextField does not crash upon double tap
325+
// when there is an inline widget in the text.
326+
// See https://github.com/superlistapp/super_editor/issues/2611 for more details.
327+
328+
final controller = AttributedTextEditingController(
329+
text: AttributedText(
330+
'< inline',
331+
null,
332+
{
333+
0: const _NamedPlaceHolder('1'),
334+
},
335+
),
336+
);
337+
338+
await _pumpTestApp(tester, controller: controller);
339+
340+
// Double tap at the inline widget.
341+
final inlineWidgetCenter = tester.getCenter(find.byPlaceholderName('1'));
342+
await tester.tapAt(inlineWidgetCenter);
343+
await tester.pump(kDoubleTapMinTime);
344+
await tester.tapAt(inlineWidgetCenter);
345+
// Wait for the double tap to be recognized.
346+
await tester.pump(kTapMinTime);
347+
348+
// Ensure the inline widget was selected.
349+
expect(
350+
SuperTextFieldInspector.findSelection(),
351+
const TextSelection(baseOffset: 0, extentOffset: 1),
352+
);
353+
});
354+
355+
testWidgetsOnAllPlatforms('does not invalidate layout when selection changes', (tester) async {
356+
final controller = AttributedTextEditingController(
357+
text: AttributedText(
358+
'Hello',
359+
null,
360+
{
361+
5: const _NamedPlaceHolder('1'),
362+
},
363+
),
364+
);
365+
366+
await _pumpTestApp(tester, controller: controller);
367+
368+
// Place the caret at the beginning of the textfield.
369+
await tester.placeCaretInSuperTextField(0);
370+
371+
// Keep track of whether of not the layout was invalidated.
372+
bool wasLayoutInvalidated = false;
373+
374+
final renderParagraph = find
375+
.byType(LayoutAwareRichText) //
376+
.evaluate()
377+
.first
378+
.findRenderObject() as RenderLayoutAwareParagraph;
379+
renderParagraph.onMarkNeedsLayout = () {
380+
wasLayoutInvalidated = true;
381+
};
382+
383+
// Place the selection somewhere else.
384+
await tester.placeCaretInSuperTextField(1);
385+
386+
// Ensure the layout was not invalidated.
387+
expect(wasLayoutInvalidated, isFalse);
388+
});
321389
});
322390
}
323391

0 commit comments

Comments
 (0)