Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SuperEditor][Mobile] Add caret customization (Resolves #1935) #2020

Merged
merged 3 commits into from
May 22, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -353,13 +353,16 @@ class SuperEditorAndroidToolbarFocalPointDocumentLayerBuilder implements SuperEd
class SuperEditorAndroidHandlesDocumentLayerBuilder implements SuperEditorLayerBuilder {
const SuperEditorAndroidHandlesDocumentLayerBuilder({
this.caretColor,
this.caretWidth = 2,
});

/// The (optional) color of the caret (not the drag handle), by default the color
/// defers to the root [SuperEditorAndroidControlsScope], or the app theme if the
/// controls controller has no preference for the color.
final Color? caretColor;

final double caretWidth;

@override
ContentLayerWidget build(BuildContext context, SuperEditorContext editContext) {
if (defaultTargetPlatform != TargetPlatform.android ||
Expand All @@ -379,6 +382,7 @@ class SuperEditorAndroidHandlesDocumentLayerBuilder implements SuperEditorLayerB
const ClearComposingRegionRequest(),
]);
},
caretWidth: caretWidth,
caretColor: caretColor,
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1824,9 +1824,16 @@ class SuperEditorIosToolbarFocalPointDocumentLayerBuilder implements SuperEditor
class SuperEditorIosHandlesDocumentLayerBuilder implements SuperEditorLayerBuilder {
const SuperEditorIosHandlesDocumentLayerBuilder({
this.handleColor,
this.caretWidth,
this.handleBallDiameter,
});

final Color? handleColor;
final double? caretWidth;

/// The diameter of the small circle that appears on the top and bottom of
/// expanded iOS text handles.
final double? handleBallDiameter;

@override
ContentLayerWidget build(BuildContext context, SuperEditorContext editContext) {
Expand All @@ -1849,6 +1856,8 @@ class SuperEditorIosHandlesDocumentLayerBuilder implements SuperEditorLayerBuild
handleColor: handleColor ??
SuperEditorIosControlsScope.maybeRootOf(context)?.handleColor ??
Theme.of(context).primaryColor,
caretWidth: caretWidth ?? 2,
handleBallDiameter: handleBallDiameter ?? defaultIosHandleBallDiameter,
shouldCaretBlink: SuperEditorIosControlsScope.rootOf(context).shouldCaretBlink,
floatingCursorController: SuperEditorIosControlsScope.rootOf(context).floatingCursorController,
);
Expand All @@ -1859,3 +1868,7 @@ const defaultIosMagnifierEnterAnimationDuration = Duration(milliseconds: 180);
const defaultIosMagnifierExitAnimationDuration = Duration(milliseconds: 150);
const defaultIosMagnifierAnimationCurve = Curves.easeInOut;
const defaultIosMagnifierSize = Size(133, 96);

/// The diameter of the small circle that appears on the top and bottom of
/// expanded iOS text handles.
const defaultIosHandleBallDiameter = 8.0;
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ class AndroidHandlesDocumentLayer extends DocumentLayoutLayerStatefulWidget {
required this.documentLayout,
required this.selection,
required this.changeSelection,
this.caretWidth = 2,
this.caretColor,
this.showDebugPaint = false,
});
Expand All @@ -163,6 +164,8 @@ class AndroidHandlesDocumentLayer extends DocumentLayoutLayerStatefulWidget {

final void Function(DocumentSelection?, SelectionChangeType, String selectionReason) changeSelection;

final double caretWidth;

/// Color used to render the Android-style caret (not handles), by default the color
/// is retrieved from the root [SuperEditorAndroidControlsController].
final Color? caretColor;
Expand Down Expand Up @@ -387,7 +390,7 @@ class AndroidControlsDocumentLayerState
left: caret.left,
top: caret.top,
height: caret.height,
width: 2,
width: widget.caretWidth,
child: Leader(
link: _controlsController!.collapsedHandleFocalPoint,
child: ListenableBuilder(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,8 @@ class IosHandlesDocumentLayer extends DocumentLayoutLayerStatefulWidget {
required this.selection,
required this.changeSelection,
required this.handleColor,
this.caretWidth = 2,
this.handleBallDiameter = defaultIosHandleBallDiameter,
required this.shouldCaretBlink,
this.floatingCursorController,
this.showDebugPaint = false,
Expand All @@ -520,6 +522,12 @@ class IosHandlesDocumentLayer extends DocumentLayoutLayerStatefulWidget {
/// Color the iOS-style text selection drag handles.
final Color handleColor;

final double caretWidth;

/// The diameter of the small circle that appears on the top and bottom of
/// expanded iOS text handles.
final double handleBallDiameter;

/// Whether the caret should blink, whenever the caret is visible.
final ValueListenable<bool> shouldCaretBlink;

Expand All @@ -538,10 +546,6 @@ class IosHandlesDocumentLayer extends DocumentLayoutLayerStatefulWidget {
@visibleForTesting
class IosControlsDocumentLayerState extends DocumentLayoutLayerState<IosHandlesDocumentLayer, DocumentSelectionLayout>
with SingleTickerProviderStateMixin {
/// The diameter of the small circle that appears on the top and bottom of
/// expanded iOS text handles.
static const ballDiameter = 8.0;

// These global keys are assigned to each draggable handle to
// prevent a strange dragging issue.
//
Expand Down Expand Up @@ -775,6 +779,7 @@ class IosControlsDocumentLayerState extends DocumentLayoutLayerState<IosHandlesD
controller: _caretBlinkController,
color: isShowingFloatingCursor ? Colors.grey : widget.handleColor,
caretHeight: caret.height,
caretWidth: widget.caretWidth,
);
},
),
Expand All @@ -788,15 +793,16 @@ class IosControlsDocumentLayerState extends DocumentLayoutLayerState<IosHandlesD
return Positioned(
key: _upstreamHandleKey,
left: upstream.left,
top: upstream.top - ballDiameter,
top: upstream.top - widget.handleBallDiameter,
child: FractionalTranslation(
translation: const Offset(-0.5, 0),
child: IOSSelectionHandle.upstream(
key: DocumentKeys.upstreamHandle,
color: widget.handleColor,
handleType: HandleType.upstream,
caretHeight: upstream.height,
ballRadius: ballDiameter / 2,
caretWidth: widget.caretWidth,
ballRadius: widget.handleBallDiameter / 2,
),
),
);
Expand All @@ -817,7 +823,8 @@ class IosControlsDocumentLayerState extends DocumentLayoutLayerState<IosHandlesD
color: widget.handleColor,
handleType: HandleType.downstream,
caretHeight: downstream.height,
ballRadius: ballDiameter / 2,
caretWidth: widget.caretWidth,
ballRadius: widget.handleBallDiameter / 2,
),
),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ class IOSSelectionHandle extends StatelessWidget {
),
),
Container(
width: 2,
width: caretWidth,
height: caretHeight + ballRadius,
color: color,
),
Expand Down Expand Up @@ -131,7 +131,7 @@ class IOSCollapsedHandle extends StatelessWidget {
controller: controller,
caretOffset: Offset.zero,
caretHeight: caretHeight,
width: 2,
width: caretWidth,
color: color,
borderRadius: BorderRadius.zero,
isTextEmpty: false,
Expand Down
14 changes: 13 additions & 1 deletion super_editor/test/super_editor/supereditor_test_tools.dart
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,16 @@ class TestSuperEditorConfigurator {
return this;
}

/// Configures the [SuperEditor] to use the given [documentLayers].
///
/// This can be used, for example, to customize the caret style for a specific platform.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a lot of responsibility to just change the caret style. Do we have similar examples of minor customizations that require this level of intervention?

I know we have keyboard handlers, but that situation is mostly about adding a few new handlers on top of existing ones.

In this case, the user is altering an existing UI property in a small way....

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated.

///
/// The default layers are ignored.
TestSuperEditorConfigurator withDocumentLayers(List<SuperEditorLayerBuilder> documentLayers) {
_config.documentLayers = documentLayers;
return this;
}

/// Configures the [SuperEditor]'s [SoftwareKeyboardController].
TestSuperEditorConfigurator withSoftwareKeyboardController(SoftwareKeyboardController controller) {
_config.softwareKeyboardController = controller;
Expand Down Expand Up @@ -555,7 +565,7 @@ class _TestSuperEditorState extends State<_TestSuperEditor> {
...(widget.testConfiguration.componentBuilders ?? defaultComponentBuilders),
],
scrollController: widget.testConfiguration.scrollController,
documentOverlayBuilders: _createOverlayBuilders(),
documentOverlayBuilders: widget.testConfiguration.documentLayers ?? _createOverlayBuilders(),
plugins: widget.testConfiguration.plugins,
);
}
Expand Down Expand Up @@ -631,6 +641,8 @@ class SuperEditorTestConfiguration {

DocumentSelection? selection;

List<SuperEditorLayerBuilder>? documentLayers;

final plugins = <SuperEditorPlugin>{};

WidgetTreeBuilder? widgetTreeBuilder;
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
93 changes: 93 additions & 0 deletions super_editor/test_goldens/editor/supereditor_caret_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:golden_toolkit/golden_toolkit.dart';
import 'package:super_editor/super_editor.dart';
import 'package:super_editor/super_editor_test.dart';

import '../../test/super_editor/supereditor_test_tools.dart';
import '../test_tools_goldens.dart';
Expand Down Expand Up @@ -95,6 +96,74 @@ void main() {
// TODO: find out why this test fails on CI only.
skip: true,
);

testGoldensOniOS('allows customizing the caret width', (tester) async {
await tester //
.createDocument()
.withSingleParagraph()
.withDocumentLayers(
_createDefaultLayersWithCustomIosHandlesLayer(
const SuperEditorIosHandlesDocumentLayerBuilder(caretWidth: 4.0),
),
)
.pump();

// Place caret at "Lorem ip|sum"
await tester.placeCaretInParagraph('1', 8);

await screenMatchesGolden(tester, 'super-editor-ios-custom-caret-width');
});

testGoldensOniOS('allows customizing the expanded handle width', (tester) async {
await tester //
.createDocument()
.withSingleParagraph()
.withDocumentLayers(
_createDefaultLayersWithCustomIosHandlesLayer(
const SuperEditorIosHandlesDocumentLayerBuilder(caretWidth: 4.0),
),
)
.pump();

// Double tap to select the word ipsum.
await tester.doubleTapInParagraph('1', 8);

await screenMatchesGolden(tester, 'super-editor-ios-custom-handle-width');
});

testGoldensOniOS('allows customizing the expanded handle ball diameter', (tester) async {
await tester //
.createDocument()
.withSingleParagraph()
.withDocumentLayers(
_createDefaultLayersWithCustomIosHandlesLayer(
const SuperEditorIosHandlesDocumentLayerBuilder(handleBallDiameter: 16.0),
),
)
.pump();

// Double tap to select the word ipsum.
await tester.doubleTapInParagraph('1', 8);

await screenMatchesGolden(tester, 'super-editor-ios-custom-handle-ball-diameter');
});

testGoldensOnAndroid('allows customizing the caret width', (tester) async {
await tester //
.createDocument()
.withSingleParagraph()
.withDocumentLayers(
_createDefaultLayersWithCustomAndroidHandlesLayer(
const SuperEditorAndroidHandlesDocumentLayerBuilder(caretWidth: 4.0),
),
)
.pump();

// Place caret at "Lorem ip|sum"
await tester.placeCaretInParagraph('1', 8);

await screenMatchesGolden(tester, 'super-editor-android-custom-caret-width');
});
});
}

Expand Down Expand Up @@ -139,3 +208,27 @@ Future<void> _pumpCaretTestApp(WidgetTester tester) async {
],
).pump();
}

/// Creates a list of [SuperEditorLayerBuilder] with the default layers, and the given [iosHandlesLayer] instead
/// of the default iOS handles layer.
List<SuperEditorLayerBuilder> _createDefaultLayersWithCustomIosHandlesLayer(
SuperEditorIosHandlesDocumentLayerBuilder iosHandlesLayer) {
return [
...defaultSuperEditorDocumentOverlayBuilders.where(
(e) => e is! SuperEditorIosHandlesDocumentLayerBuilder,
),
iosHandlesLayer,
];
}

/// Creates a list of [SuperEditorLayerBuilder] with the default layers, and the given [androidHandlesLayer] instead
/// of the default Android handles layer.
List<SuperEditorLayerBuilder> _createDefaultLayersWithCustomAndroidHandlesLayer(
SuperEditorAndroidHandlesDocumentLayerBuilder androidHandlesLayer) {
return [
...defaultSuperEditorDocumentOverlayBuilders.where(
(e) => e is! SuperEditorAndroidHandlesDocumentLayerBuilder,
),
androidHandlesLayer,
];
}
Loading