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

Let apps open and close the keyboard on demand, and let selection exist when keyboard is closed (Resolves #875) #876

Merged
merged 27 commits into from
Jan 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3aa15cb
Added a demo with a panel behind a keyboard
matthew-carroll Dec 3, 2022
0efd398
WIP: Demo can close and open keyboard, selection remains when keyboar…
matthew-carroll Dec 5, 2022
a1ba670
WIP: Debugging user typing in SuperEditor
matthew-carroll Dec 6, 2022
d8485e6
Fixed a broken test
matthew-carroll Dec 6, 2022
91d9109
Fixed a composing region bug
matthew-carroll Dec 7, 2022
034ae44
Adjusted an assertion
matthew-carroll Dec 7, 2022
d1e7267
Keyboard panel demo - apply MediaQuery viewInsets to SuperEditor so S…
matthew-carroll Dec 14, 2022
429d2b1
Tapping on a document that's being edited by a non-keyboard panel won…
matthew-carroll Dec 15, 2022
921d1db
Don't clear selection on double tap or triple tap when they can find …
matthew-carroll Dec 17, 2022
e4a8af4
Added tests, and moved IME keyboard policy to DocumentComposer
matthew-carroll Dec 19, 2022
18212ce
WIP: IME refactor checkin. All tests passing.
matthew-carroll Jan 1, 2023
4f636be
Checkin before trying to simplify IME API
matthew-carroll Jan 5, 2023
2f54f49
Changed ImeFocusPolicy to take a TextInputConnection so that it's usa…
matthew-carroll Jan 5, 2023
0ce2353
Changed DocumentSelectionOpenAndCloseImePolicy to use a regular Flutt…
matthew-carroll Jan 6, 2023
bc10e34
Converted existing Super Editor keyboard interactor into a genericall…
matthew-carroll Jan 6, 2023
5a0c29d
WIP: committing after fixing some nasty frame timing bugs in the midd…
matthew-carroll Jan 6, 2023
07a83b0
WIP: Simplified interactor tree, also changed synchronizer API to tak…
matthew-carroll Jan 7, 2023
e0e1ceb
WIP: Removed DocumentImeConnection and related APIs
matthew-carroll Jan 7, 2023
fe1dc09
WIP: Created a SoftwareKeyboard widget to open and close keyboard ins…
matthew-carroll Jan 8, 2023
1b14c5d
WIP: Moved IME opening and closing policy configurations into a new d…
matthew-carroll Jan 8, 2023
101f503
WIP: Minor refactoring checkin
matthew-carroll Jan 8, 2023
fe2c5f3
WIP: Converted DocumentComposer's concept of composing region from Te…
matthew-carroll Jan 8, 2023
4e3dd9d
Finalized API rework by reworking some complexity in document_ime_com…
matthew-carroll Jan 8, 2023
370a922
Merge branch 'main' into 875_panel-behind-keyboard
matthew-carroll Jan 8, 2023
0966c65
Renamed SoftwareKeyboard to SoftwareKeyboardOpener
matthew-carroll Jan 8, 2023
121c27f
Hide ListenableBuilder in mobile_toolbar.dart
matthew-carroll Jan 8, 2023
03bf977
Fixed keyboard controller timing issues
matthew-carroll Jan 9, 2023
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
6 changes: 6 additions & 0 deletions super_editor/.run/Panel Behind Keyboard.run.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Panel Behind Keyboard" type="FlutterRunConfigurationType" factoryName="Flutter">
<option name="filePath" value="$PROJECT_DIR$/example/lib/main_panel_behind_keyboard.dart" />
<method v="2" />
</configuration>
</component>
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ class _MobileEditingAndroidDemoState extends State<MobileEditingAndroidDemo> {
late DocumentEditor _docEditor;
late DocumentComposer _composer;
late CommonEditorOperations _docOps;
late SoftwareKeyboardHandler _softwareKeyboardHandler;

FocusNode? _editorFocusNode;
SuperEditorImeConfiguration _imeConfiguration = const SuperEditorImeConfiguration();

@override
void initState() {
Expand All @@ -36,11 +36,6 @@ class _MobileEditingAndroidDemoState extends State<MobileEditingAndroidDemo> {
composer: _composer,
documentLayoutResolver: () => _docLayoutKey.currentState as DocumentLayout,
);
_softwareKeyboardHandler = SoftwareKeyboardHandler(
editor: _docEditor,
composer: _composer,
commonOps: _docOps,
);
_editorFocusNode = FocusNode();
}

Expand All @@ -53,23 +48,29 @@ class _MobileEditingAndroidDemoState extends State<MobileEditingAndroidDemo> {

void _configureImeActionButton() {
if (_composer.selection == null || !_composer.selection!.isCollapsed) {
_composer.imeConfiguration.value = _composer.imeConfiguration.value.copyWith(
keyboardActionButton: TextInputAction.newline,
);
setState(() {
_imeConfiguration = _imeConfiguration.copyWith(
keyboardActionButton: TextInputAction.newline,
);
});
return;
}

final selectedNode = _doc.getNodeById(_composer.selection!.extent.nodeId);
if (selectedNode is ListItemNode) {
_composer.imeConfiguration.value = _composer.imeConfiguration.value.copyWith(
keyboardActionButton: TextInputAction.done,
);
setState(() {
_imeConfiguration = _imeConfiguration.copyWith(
keyboardActionButton: TextInputAction.done,
);
});
return;
}

_composer.imeConfiguration.value = _composer.imeConfiguration.value.copyWith(
keyboardActionButton: TextInputAction.newline,
);
setState(() {
_imeConfiguration = _imeConfiguration.copyWith(
keyboardActionButton: TextInputAction.newline,
);
});
}

@override
Expand All @@ -83,9 +84,9 @@ class _MobileEditingAndroidDemoState extends State<MobileEditingAndroidDemo> {
documentLayoutKey: _docLayoutKey,
editor: _docEditor,
composer: _composer,
softwareKeyboardHandler: _softwareKeyboardHandler,
gestureMode: DocumentGestureMode.android,
inputSource: TextInputSource.ime,
imeConfiguration: _imeConfiguration,
androidToolbarBuilder: (_) => AndroidTextEditingFloatingToolbar(
onCutPressed: () => _docOps.cut(),
onCopyPressed: () => _docOps.copy(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,316 @@
import 'dart:math';

import 'package:flutter/material.dart';
import 'package:super_editor/super_editor.dart';

// This demo is for Android, only. You need to make changes to the Android
// Activity config for this demo to work.
// - In AndroidManifest.xml, find the MainActivity declaration.
// set `android:windowSoftInputMode="adjustResize"`

class PanelBehindKeyboardDemo extends StatefulWidget {
const PanelBehindKeyboardDemo({
Key? key,
}) : super(key: key);

@override
State<PanelBehindKeyboardDemo> createState() => _PanelBehindKeyboardDemoState();
}

class _PanelBehindKeyboardDemoState extends State<PanelBehindKeyboardDemo> {
late final FocusNode _focusNode;
late DocumentEditor _editor;
late DocumentComposer _composer;
final _keyboardController = SoftwareKeyboardController();
final _keyboardState = ValueNotifier(_InputState.closed);
final _nonKeyboardEditorState = ValueNotifier(_InputState.closed);

@override
void initState() {
super.initState();

_focusNode = FocusNode();

_editor = DocumentEditor(
document: MutableDocument(nodes: [
ParagraphNode(
id: DocumentEditor.createNodeId(),
text: AttributedText(text: "Example Doc"),
metadata: {"blockType": header1Attribution},
),
HorizontalRuleNode(id: DocumentEditor.createNodeId()),
ParagraphNode(
id: DocumentEditor.createNodeId(),
text: AttributedText(text: "Unordered list:"),
),
ListItemNode(
id: DocumentEditor.createNodeId(),
itemType: ListItemType.unordered,
text: AttributedText(text: "Unordered 1"),
),
ListItemNode(
id: DocumentEditor.createNodeId(),
itemType: ListItemType.unordered,
text: AttributedText(text: "Unordered 2"),
),
ParagraphNode(
id: DocumentEditor.createNodeId(),
text: AttributedText(text: "Ordered list:"),
),
ListItemNode(
id: DocumentEditor.createNodeId(),
itemType: ListItemType.unordered,
text: AttributedText(text: "Ordered 1"),
),
ListItemNode(
id: DocumentEditor.createNodeId(),
itemType: ListItemType.unordered,
text: AttributedText(text: "Ordered 2"),
),
ParagraphNode(
id: DocumentEditor.createNodeId(),
text: AttributedText(text: 'A blockquote:'),
),
ParagraphNode(
id: DocumentEditor.createNodeId(),
text: AttributedText(text: 'This is a blockquote.'),
metadata: {"blockType": blockquoteAttribution},
),
ParagraphNode(
id: DocumentEditor.createNodeId(),
text: AttributedText(text: 'Some code:'),
),
ParagraphNode(
id: DocumentEditor.createNodeId(),
text: AttributedText(text: '{\n // This is come code.\n}'),
),
ParagraphNode(
id: DocumentEditor.createNodeId(),
text: AttributedText(text: "Header"),
metadata: {"blockType": header2Attribution},
),
ParagraphNode(
id: DocumentEditor.createNodeId(),
text: AttributedText(text: 'More stuff 1'),
),
ParagraphNode(
id: DocumentEditor.createNodeId(),
text: AttributedText(text: 'More stuff 2'),
),
]),
);

_composer = DocumentComposer() //
..selectionNotifier.addListener(_onSelectionChange);

WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
// Check the IME connection at the end of the frame so that SuperEditor has
// an opportunity to connect to our software keyboard controller.
_keyboardState.value = _keyboardController.isConnectedToIme ? _InputState.open : _InputState.closed;
});
}

@override
void dispose() {
_closeKeyboard();
_composer.dispose();
_focusNode.dispose();
super.dispose();
}

void _onSelectionChange() {
print("Demo: _onSelectionChange()");
print(" - selection: ${_composer.selection}");
if (_nonKeyboardEditorState.value == _InputState.open) {
// If the user is currently editing with the non-keyboard editing
// panel, don't open the keyboard to cover it.
return;
}

if (_composer.selection == null) {
// If there's no selection, we don't want to pop open the keyboard.
return;
}

print("Opening keyboard from _onSelectionChange()");
_openKeyboard();
}

void _openKeyboard() {
print("Opening keyboard (also connecting to IME, if needed)");
_keyboardController.open();
}

void _closeKeyboard() {
print("Closing keyboard (and disconnecting from IME)");
_keyboardController.close();
}

void _endEditing() {
print("End editing");
_keyboardController.close();
_composer.selection = null;

// If we clear SuperEditor's selection, but leave SuperEditor focused, then
// SuperEditor will automatically place the caret at the end of the document.
// This is because SuperEditor always expects a place for text input when it
// has focus. To prevent this from happening, we explicitly remove focus
// from SuperEditor.
_focusNode.unfocus();
}

@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: false,
body: Stack(
children: [
Positioned.fill(
child: Padding(
padding: MediaQuery.of(context).viewInsets,
child: SuperEditor(
focusNode: _focusNode,
editor: _editor,
composer: _composer,
softwareKeyboardController: _keyboardController,
imePolicies: SuperEditorImePolicies(
openKeyboardOnSelectionChange: false,
clearSelectionWhenImeDisconnects: false,
),
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: BehindKeyboardPanel(
keyboardState: _keyboardState,
nonKeyboardEditorState: _nonKeyboardEditorState,
onOpenKeyboard: _openKeyboard,
onCloseKeyboard: _closeKeyboard,
onEndEditing: _endEditing,
),
),
],
),
);
}
}

class BehindKeyboardPanel extends StatefulWidget {
const BehindKeyboardPanel({
Key? key,
required this.keyboardState,
required this.nonKeyboardEditorState,
required this.onOpenKeyboard,
required this.onCloseKeyboard,
required this.onEndEditing,
}) : super(key: key);

final ValueNotifier<_InputState> keyboardState;
final ValueNotifier<_InputState> nonKeyboardEditorState;
final VoidCallback onOpenKeyboard;
final VoidCallback onCloseKeyboard;
final VoidCallback onEndEditing;

@override
State<BehindKeyboardPanel> createState() => _BehindKeyboardPanelState();
}

class _BehindKeyboardPanelState extends State<BehindKeyboardPanel> {
double _maxBottomInsets = 0.0;
double _latestBottomInsets = 0.0;

@override
void didChangeDependencies() {
super.didChangeDependencies();

final newBottomInset = MediaQuery.of(context).viewInsets.bottom;
print("BehindKeyboardPanel didChangeDependencies() - bottom inset: $newBottomInset");
if (newBottomInset > _maxBottomInsets) {
print("Setting max bottom insets to: $newBottomInset");
_maxBottomInsets = newBottomInset;
widget.nonKeyboardEditorState.value = _InputState.open;

if (widget.keyboardState.value != _InputState.open) {
setState(() {
widget.keyboardState.value = _InputState.open;
});
}
} else if (newBottomInset > _latestBottomInsets) {
print("Keyboard is opening. We're already expanded");
// The keyboard is expanding, but we're already expanded. Make sure
// that our internal accounting for keyboard state is updated.
if (widget.keyboardState.value != _InputState.open) {
setState(() {
widget.keyboardState.value = _InputState.open;
});
}
} else if (widget.nonKeyboardEditorState.value == _InputState.closed) {
// We don't want to be expanded. Follow the keyboard back down.
_maxBottomInsets = newBottomInset;
} else {
// The keyboard is collapsing, but we want to stay expanded. Make sure
// our internal accounting for keyboard state is updated.
if (widget.keyboardState.value == _InputState.open) {
setState(() {
widget.keyboardState.value = _InputState.closed;
});
}
}

_latestBottomInsets = newBottomInset;
}

void _closeKeyboardAndPanel() {
setState(() {
widget.nonKeyboardEditorState.value = _InputState.closed;
_maxBottomInsets = min(_latestBottomInsets, _maxBottomInsets);
});

widget.onEndEditing();
}

@override
Widget build(BuildContext context) {
print("Building toolbar. Is expanded? ${widget.keyboardState.value == _InputState.open}");
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: double.infinity,
height: 54,
color: Colors.grey.shade100,
child: Row(
children: [
const SizedBox(width: 24),
GestureDetector(
onTap: _closeKeyboardAndPanel,
child: Icon(Icons.close),
),
Spacer(),
GestureDetector(
onTap: widget.keyboardState.value == _InputState.open ? widget.onCloseKeyboard : widget.onOpenKeyboard,
child: Icon(widget.keyboardState.value == _InputState.open ? Icons.keyboard_hide : Icons.keyboard),
),
const SizedBox(width: 24),
],
),
),
SizedBox(
width: double.infinity,
height: _maxBottomInsets,
child: ColoredBox(
color: Colors.grey.shade300,
),
),
],
);
}
}

enum _InputState {
open,
closed,
}
Loading