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][Android][iOS] - Add tooling to more easily create keyboard toolbar experiences (Resolves #2209) #2216

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 12 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

Large diffs are not rendered by default.

222 changes: 202 additions & 20 deletions super_editor/example/lib/demos/mobile_chat/demo_mobile_chat.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,63 @@ class MobileChatDemo extends StatefulWidget {
}

class _MobileChatDemoState extends State<MobileChatDemo> {
final FocusNode _focusNode = FocusNode();
late final Editor _editor;

late final KeyboardPanelController _keyboardPanelController;
final SoftwareKeyboardController _softwareKeyboardController = SoftwareKeyboardController();

final _imeConnectionNotifier = ValueNotifier<bool>(false);

_Panel? _visiblePanel;

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

final document = MutableDocument.empty();
final composer = MutableDocumentComposer();
_editor = createDefaultDocumentEditor(document: document, composer: composer);

_keyboardPanelController = KeyboardPanelController(_softwareKeyboardController);
}

@override
void dispose() {
_imeConnectionNotifier.dispose();
_keyboardPanelController.dispose();
_focusNode.dispose();
super.dispose();
}

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

@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned.fill(
child: ColoredBox(color: Colors.white),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _buildCommentEditor(),
),
],
return KeyboardScaffoldSafeArea(
child: Stack(
children: [
Positioned.fill(
child: ColoredBox(color: Colors.white),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _buildCommentEditor(),
),
],
),
);
}

Expand Down Expand Up @@ -63,20 +95,125 @@ class _MobileChatDemoState extends State<MobileChatDemo> {
],
),
padding: const EdgeInsets.only(top: 16, bottom: 24),
child: CustomScrollView(
shrinkWrap: true,
slivers: [
SuperEditor(
editor: _editor,
child: KeyboardPanelScaffold(
controller: _keyboardPanelController,
isImeConnected: _imeConnectionNotifier,
toolbarBuilder: _buildKeyboardToolbar,
keyboardPanelBuilder: (context) {
switch (_visiblePanel) {
case _Panel.panel1:
return Container(
color: Colors.blue,
height: double.infinity,
);
case _Panel.panel2:
return Container(
color: Colors.red,
height: double.infinity,
);
default:
return const SizedBox();
}
},
contentBuilder: (context, isKeyboardVisible) {
return CustomScrollView(
shrinkWrap: true,
stylesheet: _chatStylesheet,
),
],
slivers: [
SuperEditor(
editor: _editor,
focusNode: _focusNode,
softwareKeyboardController: _softwareKeyboardController,
shrinkWrap: true,
stylesheet: _chatStylesheet,
selectionPolicies: const SuperEditorSelectionPolicies(
clearSelectionWhenEditorLosesFocus: false,
clearSelectionWhenImeConnectionCloses: false,
),
isImeConnected: _imeConnectionNotifier,
),
],
);
},
),
),
],
);
}

Widget _buildKeyboardToolbar(BuildContext context, bool isKeyboardPanelVisible) {
if (!isKeyboardPanelVisible) {
_visiblePanel = null;
}

return Container(
width: double.infinity,
height: 54,
color: Colors.grey.shade100,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
const SizedBox(width: 24),
const Spacer(),
_PanelButton(
icon: Icons.text_fields,
isActive: _visiblePanel == _Panel.panel1,
onPressed: () => _togglePanel(_Panel.panel1),
),
const SizedBox(width: 16),
_PanelButton(
icon: Icons.align_horizontal_left,
isActive: _visiblePanel == _Panel.panel2,
onPressed: () => _togglePanel(_Panel.panel2),
),
const SizedBox(width: 16),
_PanelButton(
icon: Icons.account_circle,
onPressed: () => _showBottomSheetWithOptions(context),
),
const Spacer(),
GestureDetector(
onTap: _keyboardPanelController.closeKeyboardAndPanel,
child: Icon(Icons.keyboard_hide),
),
const SizedBox(width: 24),
],
),
);
}
}

enum _Panel {
panel1,
panel2;
}

class _PanelButton extends StatelessWidget {
const _PanelButton({
required this.icon,
this.isActive = false,
required this.onPressed,
});

final IconData icon;
final bool isActive;
final VoidCallback onPressed;

@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onPressed,
child: AspectRatio(
aspectRatio: 1.0,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: isActive ? Colors.grey : Colors.transparent,
),
child: Icon(icon),
),
),
);
}
}

final _chatStylesheet = defaultStylesheet.copyWith(
Expand Down Expand Up @@ -108,3 +245,48 @@ final _chatStylesheet = defaultStylesheet.copyWith(
),
],
);

Future<void> _showBottomSheetWithOptions(BuildContext context) async {
return showModalBottomSheet(
context: context,
builder: (sheetContext) {
return _BottomSheetWithoutButtonOptions();
},
);
}

class _BottomSheetWithoutButtonOptions extends StatefulWidget {
const _BottomSheetWithoutButtonOptions();

@override
State<_BottomSheetWithoutButtonOptions> createState() => _BottomSheetWithoutButtonOptionsState();
}

class _BottomSheetWithoutButtonOptionsState extends State<_BottomSheetWithoutButtonOptions> {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
"This bottom sheet represents a feature in which the user wants to temporarily leave the editor, and the toolbar, to review or select an option. We expect the keyboard or panel to close when this opens, and to re-open when this closes.",
textAlign: TextAlign.left,
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text("Some Options"),
),
],
),
);
}
}
1 change: 1 addition & 0 deletions super_editor/example/lib/main_super_editor_chat.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ void main() {
runApp(
MaterialApp(
home: Scaffold(
resizeToAvoidBottomInset: false,
body: MobileChatDemo(),
),
debugShowCheckedModeBanner: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -985,6 +985,7 @@ class _AndroidDocumentTouchInteractorState extends State<AndroidDocumentTouchInt
..hideToolbar()
..doNotBlinkCaret();
} else if (!widget.selection.value!.isCollapsed) {
// The selection is expanded.
_controlsController!
..hideCollapsedHandle()
..showExpandedHandles()
Expand All @@ -1002,9 +1003,13 @@ class _AndroidDocumentTouchInteractorState extends State<AndroidDocumentTouchInt
..hideMagnifier()
..blinkCaret();

if (didTapOnExistingSelection) {
if (didTapOnExistingSelection && _isKeyboardOpen) {
// Toggle the toolbar display when the user taps on the collapsed caret,
// or on top of an existing selection.
//
// But we only do this when the keyboard is already open. This is because
// we don't want to show the toolbar when the user taps simply to open
// the keyboard. That would feel unintentional, like a bug.
_controlsController!.toggleToolbar();
} else {
// The user tapped somewhere else in the document. Hide the toolbar.
Expand All @@ -1013,6 +1018,16 @@ class _AndroidDocumentTouchInteractorState extends State<AndroidDocumentTouchInt
}
}

/// Returns `true` if we *think* the software keyboard is currently open, or
/// `false` otherwise.
///
/// We say "think" because Flutter doesn't report this info to us. Instead, we
/// inspect the bottom insets on the window, and we assume any insets greater than
/// zero means a keyboard is visible.
bool get _isKeyboardOpen {
return MediaQuery.viewInsetsOf(context).bottom > 0;
}

void _onPanStart(DragStartDetails details) {
// Stop waiting for a long-press to start, if a long press isn't already in-progress.
_tapDownLongPressTimer?.cancel();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -695,9 +695,13 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
selection.extent.nodeId == adjustedSelectionPosition.nodeId &&
selection.extent.nodePosition.isEquivalentTo(adjustedSelectionPosition.nodePosition);

if (didTapOnExistingSelection) {
if (didTapOnExistingSelection && _isKeyboardOpen) {
// Toggle the toolbar display when the user taps on the collapsed caret,
// or on top of an existing selection.
//
// But we only do this when the keyboard is already open. This is because
// we don't want to show the toolbar when the user taps simply to open
// the keyboard. That would feel unintentional, like a bug.
_controlsController!.toggleToolbar();
} else {
// The user tapped somewhere else in the document. Hide the toolbar.
Expand Down Expand Up @@ -740,6 +744,16 @@ class _IosDocumentTouchInteractorState extends State<IosDocumentTouchInteractor>
widget.focusNode.requestFocus();
}

/// Returns `true` if we *think* the software keyboard is currently open, or
/// `false` otherwise.
///
/// We say "think" because Flutter doesn't report this info to us. Instead, we
/// inspect the bottom insets on the window, and we assume any insets greater than
/// zero means a keyboard is visible.
bool get _isKeyboardOpen {
return MediaQuery.viewInsetsOf(context).bottom > 0;
}

DocumentPosition _moveTapPositionToWordBoundary(DocumentPosition docPosition) {
if (!SuperEditorIosControlsScope.rootOf(context).useIosSelectionHeuristics) {
// iOS-style adjustments aren't desired. Don't adjust th given position.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ class _SoftwareKeyboardOpenerState extends State<SoftwareKeyboardOpener> impleme
widget.imeConnection.value!.show();
}

@override
void hide() {
SystemChannels.textInput.invokeListMethod("TextInput.hide");
}

@override
void close() {
editorImeLog.info("[SoftwareKeyboard] - closing IME connection.");
Expand Down Expand Up @@ -125,6 +130,11 @@ class SoftwareKeyboardController {
_delegate?.open();
}

void hide() {
assert(hasDelegate);
_delegate?.hide();
}

/// Closes the software keyboard.
void close() {
assert(hasDelegate);
Expand All @@ -141,6 +151,9 @@ abstract class SoftwareKeyboardControllerDelegate {
/// Opens the software keyboard.
void open();

/// Closes the software keyboard.
/// Hides the software keyboard without closing the IME connection.
void hide();

/// Closes the software keyboard, and the IME connection.
void close();
}
Loading
Loading