Skip to content

Commit

Permalink
Let apps open and close the keyboard on demand, and let selection exi…
Browse files Browse the repository at this point in the history
…st when keyboard is closed (Resolves #875) (#876)

* Includes general refactoring and packaging of Super Editor IME behavior
  • Loading branch information
matthew-carroll committed Jan 10, 2023
1 parent 7192947 commit 2ef77ee
Show file tree
Hide file tree
Showing 40 changed files with 3,533 additions and 1,670 deletions.
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

0 comments on commit 2ef77ee

Please sign in to comment.