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 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
26eaa5e
[SuperEditor][Android][iOS] - Add tooling to more easily create keybo…
angelosilvestre Aug 5, 2024
eb81ed7
PR updates
angelosilvestre Aug 6, 2024
082c0d7
Display panels in an OverlayPortal
angelosilvestre Aug 12, 2024
aeea934
Add option to automatically show/hide the above-keyboard panel
angelosilvestre Sep 24, 2024
cc874f8
Add widget to resize the tree according to the panel size
angelosilvestre Sep 25, 2024
c416827
Merge branch 'main' into 2209_panel-behind-keyboard
matthew-carroll Sep 27, 2024
f083ab3
Change keyboard scaffold controller to hide keyboard instead of close…
matthew-carroll Sep 28, 2024
41e80b5
Reworking keyboard scaffold API
matthew-carroll Oct 2, 2024
7827ec9
Automatically close the panel when the keyboard comes up on its own.
matthew-carroll Oct 12, 2024
f5c36ac
Don't show mobile toolbar when tapping to open the keyboard over a panel
matthew-carroll Oct 13, 2024
2ac9f6e
Automatically close an open panel when the IME disconnects
matthew-carroll Oct 14, 2024
b1bd4b1
Added test for auto-closing panel when IME closes
matthew-carroll Oct 14, 2024
0b38523
WIP: Filling out tests for tablets
matthew-carroll Oct 18, 2024
ea81984
Added some tablet tests for minimized keyboard area
matthew-carroll Oct 20, 2024
083a84c
Fixed some erroneous situations with bottom padding related to OS notch
matthew-carroll Oct 25, 2024
c644066
Allow multiple keyboard panel safe areas in the same subtree.
matthew-carroll Nov 4, 2024
20f5be8
Set keyboard safe area to zero when scaffold is disposed
matthew-carroll Nov 5, 2024
89622f2
Don't access ancestor safe area from setter because we might disposed…
matthew-carroll Nov 5, 2024
6bf6888
WIP: Still need to fill out tests, remove print statements, and final…
matthew-carroll Nov 6, 2024
223dad1
Added safe area tests
matthew-carroll Nov 8, 2024
963e221
Removed old keyboard panel demo in favor of dedicated chat demo
matthew-carroll Nov 8, 2024
083ef69
Fixed tests
matthew-carroll Nov 8, 2024
f74b6ce
Add some demo docs
matthew-carroll Nov 8, 2024
80ffa01
Merge branch 'main' into 2209_panel-behind-keyboard
matthew-carroll Nov 9, 2024
a5259f8
PR updates after review
matthew-carroll Nov 13, 2024
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