Skip to content

Commit

Permalink
[SuperEditor] - Make it possible to open a keyboard panel before the …
Browse files Browse the repository at this point in the history
…keyboard opens (Resolves #2438) (#2476)
  • Loading branch information
matthew-carroll authored and web-flow committed Dec 23, 2024
1 parent 4247200 commit 4d81ec8
Show file tree
Hide file tree
Showing 13 changed files with 468 additions and 378 deletions.
123 changes: 100 additions & 23 deletions super_editor/example/lib/demos/mobile_chat/demo_mobile_chat.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import 'package:example/demos/mobile_chat/giphy_keyboard_panel.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:super_editor/super_editor.dart';
import 'package:super_keyboard/super_keyboard.dart';

/// A UI with a chat message editor at the bottom, and a fake chat conversation
/// behind it.
Expand Down Expand Up @@ -33,17 +35,18 @@ class _MobileChatDemoState extends State<MobileChatDemo> {
final FocusNode _editorFocusNode = FocusNode();
late final Editor _editor;

late final KeyboardPanelController _keyboardPanelController;
late final KeyboardPanelController<_Panel> _keyboardPanelController;
final SoftwareKeyboardController _softwareKeyboardController = SoftwareKeyboardController();

final _imeConnectionNotifier = ValueNotifier<bool>(false);

_Panel? _visiblePanel;

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

SuperKeyboard.initLogs();
initLoggers(Level.ALL, {keyboardPanelLog});

final document = MutableDocument.empty();
final composer = MutableDocumentComposer();
_editor = createDefaultDocumentEditor(document: document, composer: composer);
Expand All @@ -64,16 +67,36 @@ class _MobileChatDemoState extends State<MobileChatDemo> {
super.dispose();
}

void _openPanelFromAppBar() {
// This action is here to verify that we can open keyboard panels
// before opening the keyboard.

// Focus the editor and place the caret.
_editorFocusNode.requestFocus();
final document = _editor.context.document;
_editor.execute([
ChangeSelectionRequest(
DocumentSelection.collapsed(
position: DocumentPosition(
nodeId: document.last.id,
nodePosition: document.last.endPosition,
),
),
SelectionChangeType.placeCaret,
SelectionReason.userInteraction,
),
]);

// Open a panel.
_keyboardPanelController.showKeyboardPanel(_Panel.panel1);
}

void _togglePanel(_Panel panel) {
setState(() {
if (_visiblePanel == panel) {
_visiblePanel = null;
_keyboardPanelController.showSoftwareKeyboard();
} else {
_visiblePanel = panel;
_keyboardPanelController.showKeyboardPanel();
}
});
if (_keyboardPanelController.openPanel == panel) {
_keyboardPanelController.showSoftwareKeyboard();
} else {
_keyboardPanelController.showKeyboardPanel(panel);
}
}

@override
Expand All @@ -95,6 +118,18 @@ class _MobileChatDemoState extends State<MobileChatDemo> {

PreferredSizeWidget _buildAppBar() {
return AppBar(
actions: [
IconButton(
icon: Icon(Icons.open_in_new),
onPressed: _openPanelFromAppBar,
),
IconButton(
icon: Icon(Icons.settings),
onPressed: () {
Navigator.of(context).pushNamed("/second");
},
),
],
bottom: const TabBar(
tabs: [
Tab(icon: Icon(Icons.chat)),
Expand All @@ -111,6 +146,7 @@ class _MobileChatDemoState extends State<MobileChatDemo> {
child: GestureDetector(
onTap: () {
_screenFocusNode.requestFocus();
_keyboardPanelController.closeKeyboardAndPanel();
},
child: Focus(
focusNode: _screenFocusNode,
Expand Down Expand Up @@ -158,15 +194,15 @@ class _MobileChatDemoState extends State<MobileChatDemo> {

Widget _buildCommentEditor() {
return Opacity(
// Opacity is here so we can easily check what's behind it.
opacity: 1.0,
// ^ opacity is for testing, so we can see the chat behind it.
child: KeyboardPanelScaffold(
child: KeyboardPanelScaffold<_Panel>(
controller: _keyboardPanelController,
isImeConnected: _imeConnectionNotifier,
toolbarBuilder: _buildKeyboardToolbar,
fallbackPanelHeight: MediaQuery.sizeOf(context).height / 3,
keyboardPanelBuilder: (context) {
switch (_visiblePanel) {
keyboardPanelBuilder: (context, panel) {
switch (panel) {
case _Panel.panel1:
return Container(
color: Colors.blue,
Expand Down Expand Up @@ -224,10 +260,20 @@ class _MobileChatDemoState extends State<MobileChatDemo> {
shrinkWrap: true,
stylesheet: _chatStylesheet,
selectionPolicies: const SuperEditorSelectionPolicies(
openKeyboardWhenTappingExistingSelection: false,
clearSelectionWhenEditorLosesFocus: true,
clearSelectionWhenImeConnectionCloses: false,
),
imePolicies: SuperEditorImePolicies(
openKeyboardOnGainPrimaryFocus: false,
openKeyboardOnSelectionChange: false,
closeKeyboardOnSelectionLost: false,
),
isImeConnected: _imeConnectionNotifier,
contentTapDelegateFactories: [
superEditorLaunchLinkTapHandlerFactory,
_tapToFocusEditor,
],
),
),
],
Expand All @@ -239,11 +285,14 @@ class _MobileChatDemoState extends State<MobileChatDemo> {
);
}

Widget _buildKeyboardToolbar(BuildContext context, bool isKeyboardPanelVisible) {
if (!isKeyboardPanelVisible) {
_visiblePanel = null;
}
ContentTapDelegate _tapToFocusEditor(SuperEditorContext editContext) {
return _TapToFocusEditor(
_editorFocusNode,
_keyboardPanelController,
);
}

Widget _buildKeyboardToolbar(BuildContext context, _Panel? openPanel) {
return Row(
children: [
Expanded(
Expand All @@ -258,13 +307,13 @@ class _MobileChatDemoState extends State<MobileChatDemo> {
const Spacer(),
_PanelButton(
icon: Icons.text_fields,
isActive: _visiblePanel == _Panel.panel1,
isActive: _keyboardPanelController.openPanel == _Panel.panel1,
onPressed: () => _togglePanel(_Panel.panel1),
),
const SizedBox(width: 16),
_PanelButton(
icon: Icons.align_horizontal_left,
isActive: _visiblePanel == _Panel.panel2,
isActive: _keyboardPanelController.openPanel == _Panel.panel2,
onPressed: () => _togglePanel(_Panel.panel2),
),
const SizedBox(width: 16),
Expand All @@ -279,7 +328,13 @@ class _MobileChatDemoState extends State<MobileChatDemo> {
),
const Spacer(),
GestureDetector(
onTap: _keyboardPanelController.closeKeyboardAndPanel,
onTap: () {
_keyboardPanelController.closeKeyboardAndPanel();

// We need to explicitly unfocus so that the caret doesn't
// keep blinking in the editor.
_editorFocusNode.unfocus();
},
child: Icon(Icons.keyboard_hide),
),
const SizedBox(width: 24),
Expand All @@ -301,6 +356,28 @@ class _MobileChatDemoState extends State<MobileChatDemo> {
}
}

class _TapToFocusEditor extends ContentTapDelegate {
_TapToFocusEditor(
this.editorFocusNode,
this.keyboardPanelController,
);

final FocusNode editorFocusNode;
final KeyboardPanelController keyboardPanelController;

@override
TapHandlingInstruction onTap(DocumentTapDetails details) {
if (!keyboardPanelController.isSoftwareKeyboardOpen && !keyboardPanelController.isKeyboardPanelOpen) {
// The user tapped on the editor and the software keyboard isn't up, nor is a panel.
// Open the software keyboard.
editorFocusNode.requestFocus();
keyboardPanelController.showSoftwareKeyboard();
}

return TapHandlingInstruction.continueHandling;
}
}

enum _Panel {
panel1,
panel2,
Expand Down
10 changes: 0 additions & 10 deletions super_editor/example/lib/main_super_editor_chat.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,6 @@ void main() {
MaterialApp(
routes: {
"/": (context) => Scaffold(
appBar: AppBar(
actions: [
IconButton(
onPressed: () {
Navigator.of(context).pushNamed("/second");
},
icon: Icon(Icons.settings),
),
],
),
resizeToAvoidBottomInset: false,
body: MobileChatDemo(),
),
Expand Down
1 change: 1 addition & 0 deletions super_editor/example/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ dependencies:
git:
url: https://github.com/superlistapp/super_editor.git
path: super_text_layout
super_keyboard: ^0.1.0
follow_the_leader: ^0.0.4+7
overlord: ^0.0.3+5

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ class AndroidDocumentTouchInteractor extends StatefulWidget {
required this.document,
required this.getDocumentLayout,
required this.selection,
this.openKeyboardWhenTappingExistingSelection = true,
required this.openSoftwareKeyboard,
required this.scrollController,
required this.fillViewport,
Expand All @@ -419,6 +420,9 @@ class AndroidDocumentTouchInteractor extends StatefulWidget {
final DocumentLayout Function() getDocumentLayout;
final ValueListenable<DocumentSelection?> selection;

/// {@macro openKeyboardWhenTappingExistingSelection}
final bool openKeyboardWhenTappingExistingSelection;

/// A callback that should open the software keyboard when invoked.
final VoidCallback openSoftwareKeyboard;

Expand Down Expand Up @@ -780,7 +784,7 @@ class _AndroidDocumentTouchInteractorState extends State<AndroidDocumentTouchInt

_showAndHideEditingControlsAfterTapSelection(didTapOnExistingSelection: didTapOnExistingSelection);

if (didTapOnExistingSelection) {
if (didTapOnExistingSelection && widget.openKeyboardWhenTappingExistingSelection) {
// The user tapped on the existing selection. Show the software keyboard.
//
// If the user didn't tap on an existing selection, the software keyboard will
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ class IosDocumentTouchInteractor extends StatefulWidget {
required this.document,
required this.getDocumentLayout,
required this.selection,
this.openKeyboardWhenTappingExistingSelection = true,
required this.openSoftwareKeyboard,
required this.scrollController,
required this.dragHandleAutoScroller,
Expand All @@ -260,6 +261,9 @@ class IosDocumentTouchInteractor extends StatefulWidget {
final DocumentLayout Function() getDocumentLayout;
final ValueListenable<DocumentSelection?> selection;

/// {@macro openKeyboardWhenTappingExistingSelection}
final bool openKeyboardWhenTappingExistingSelection;

/// A callback that should open the software keyboard when invoked.
final VoidCallback openSoftwareKeyboard;

Expand Down Expand Up @@ -607,7 +611,11 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
// The user tapped on an expanded selection. Toggle the toolbar and show
// the software keyboard.
_controlsController!.toggleToolbar();
widget.openSoftwareKeyboard();

if (widget.openKeyboardWhenTappingExistingSelection) {
widget.openSoftwareKeyboard();
}

return;
}

Expand Down Expand Up @@ -659,7 +667,7 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
_selectPosition(adjustedSelectionPosition);
}

if (didTapOnExistingSelection) {
if (didTapOnExistingSelection && widget.openKeyboardWhenTappingExistingSelection) {
// The user tapped on the existing selection. Show the software keyboard.
//
// If the user didn't tap on an existing selection, the software keyboard will
Expand Down
27 changes: 27 additions & 0 deletions super_editor/lib/src/default_editor/super_editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,7 @@ class SuperEditorState extends State<SuperEditor> {
document: editContext.document,
getDocumentLayout: () => editContext.documentLayout,
selection: editContext.composer.selectionNotifier,
openKeyboardWhenTappingExistingSelection: widget.selectionPolicies.openKeyboardWhenTappingExistingSelection,
openSoftwareKeyboard: _openSoftareKeyboard,
contentTapHandlers: _contentTapHandlers,
scrollController: _scrollController,
Expand All @@ -897,6 +898,7 @@ class SuperEditorState extends State<SuperEditor> {
document: editContext.document,
getDocumentLayout: () => editContext.documentLayout,
selection: editContext.composer.selectionNotifier,
openKeyboardWhenTappingExistingSelection: widget.selectionPolicies.openKeyboardWhenTappingExistingSelection,
openSoftwareKeyboard: _openSoftareKeyboard,
contentTapHandlers: _contentTapHandlers,
scrollController: _scrollController,
Expand Down Expand Up @@ -1154,6 +1156,7 @@ class SuperEditorSelectionPolicies {
const SuperEditorSelectionPolicies({
this.placeCaretAtEndOfDocumentOnGainFocus = true,
this.restorePreviousSelectionOnGainFocus = true,
this.openKeyboardWhenTappingExistingSelection = true,
this.clearSelectionWhenEditorLosesFocus = true,
this.clearSelectionWhenImeConnectionCloses = true,
});
Expand All @@ -1168,6 +1171,30 @@ class SuperEditorSelectionPolicies {
/// focus, after having previous lost focus.
final bool restorePreviousSelectionOnGainFocus;

/// {@template openKeyboardWhenTappingExistingSelection}
/// Whether the software keyboard should be opened when the user taps on the existing
/// selection.
///
/// Defaults to `true`.
///
/// Typically, when an editor has a selection, the software keyboard is already open.
/// However, in some cases, the user might want to temporarily close the keyboard. For
/// example, the user might replace the keyboard with a custom emoji picker panel.
///
/// When the user is done with the temporary keyboard replacement, the user then wants to
/// open the keyboard again, so the user taps on the caret. If this property is `true`
/// then tapping on the caret will open the keyboard again.
///
/// In other, similar cases, the user might want to be able to tap on the editor without
/// opening the keyboard. For example, the user might open a keyboard panel that can insert
/// various types of content. In that case, the user might want to move the caret to then
/// insert something from the panel. In this case, it's easy to accidentally tap on the
/// existing caret, which would then close the panel and open the keyboard. To avoid this
/// annoyance, this property can be set to `false`, in which case tapping on the caret won't
/// automatically open the keyboard. It's left to the app to re-open the keyboard when desired.
/// {@endtemplate}
final bool openKeyboardWhenTappingExistingSelection;

/// Whether the editor's selection should be removed when the editor loses
/// all focus (not just primary focus).
///
Expand Down
2 changes: 2 additions & 0 deletions super_editor/lib/src/infrastructure/_logging.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class LogNames {
static const iosTextField = 'textfield.ios';

static const infrastructure = 'infrastructure';
static const keyboardPanel = 'infrastructure.keyboardPanel';
static const longPressSelection = 'infrastructure.gestures.longPress';
static const scheduler = 'infrastructure.scheduler';
static const contentLayers = 'infrastructure.content_layers';
Expand Down Expand Up @@ -88,6 +89,7 @@ final iosTextFieldLog = logging.Logger(LogNames.iosTextField);

final docGesturesLog = logging.Logger(LogNames.documentGestures);
final infrastructureLog = logging.Logger(LogNames.infrastructure);
final keyboardPanelLog = logging.Logger(LogNames.keyboardPanel);
final longPressSelectionLog = logging.Logger(LogNames.longPressSelection);
final schedulerLog = logging.Logger(LogNames.scheduler);
final contentLayersLog = logging.Logger(LogNames.contentLayers);
Expand Down
Loading

0 comments on commit 4d81ec8

Please sign in to comment.