diff --git a/super_editor/example/lib/demos/experiments/demo_panel_behind_keyboard.dart b/super_editor/example/lib/demos/experiments/demo_panel_behind_keyboard.dart index e20908888..e360b805e 100644 --- a/super_editor/example/lib/demos/experiments/demo_panel_behind_keyboard.dart +++ b/super_editor/example/lib/demos/experiments/demo_panel_behind_keyboard.dart @@ -1,5 +1,3 @@ -import 'dart:math'; - import 'package:flutter/material.dart'; import 'package:super_editor/super_editor.dart'; @@ -22,31 +20,30 @@ class _PanelBehindKeyboardDemoState extends State { late MutableDocument _doc; late MutableDocumentComposer _composer; late Editor _editor; + + final _isImeConnected = ValueNotifier(false); + final _keyboardController = SoftwareKeyboardController(); - final _keyboardState = ValueNotifier(_InputState.closed); - final _nonKeyboardEditorState = ValueNotifier(_InputState.closed); + late final KeyboardPanelController _keyboardPanelController; + bool _isKeyboardPanelVisible = false; @override void initState() { super.initState(); + _keyboardPanelController = KeyboardPanelController(_keyboardController); + _focusNode = FocusNode(); _doc = _createDocument(); _composer = MutableDocumentComposer() // ..selectionNotifier.addListener(_onSelectionChange); _editor = createDefaultDocumentEditor(document: _doc, composer: _composer); - - 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(); + _isImeConnected.dispose(); _composer.dispose(); _focusNode.dispose(); super.dispose(); @@ -124,7 +121,7 @@ class _PanelBehindKeyboardDemoState extends State { void _onSelectionChange() { print("Demo: _onSelectionChange()"); print(" - selection: ${_composer.selection}"); - if (_nonKeyboardEditorState.value == _InputState.open) { + if (_isKeyboardPanelVisible) { // If the user is currently editing with the non-keyboard editing // panel, don't open the keyboard to cover it. return; @@ -144,14 +141,9 @@ class _PanelBehindKeyboardDemoState extends State { _keyboardController.open(); } - void _closeKeyboard() { - print("Closing keyboard (and disconnecting from IME)"); - _keyboardController.close(); - } - void _endEditing() { print("End editing"); - _keyboardController.close(); + _keyboardPanelController.closeKeyboardAndPanel(); _editor.execute([ const ClearSelectionRequest(), @@ -169,155 +161,225 @@ class _PanelBehindKeyboardDemoState extends State { 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, - softwareKeyboardController: _keyboardController, - selectionPolicies: const SuperEditorSelectionPolicies( - clearSelectionWhenEditorLosesFocus: false, - ), - imePolicies: const SuperEditorImePolicies( - openKeyboardOnSelectionChange: false, - ), - ), - ), - ), - Positioned( - left: 0, - right: 0, - bottom: 0, - child: _BehindKeyboardPanel( - keyboardState: _keyboardState, - nonKeyboardEditorState: _nonKeyboardEditorState, - onOpenKeyboard: _openKeyboard, - onCloseKeyboard: _closeKeyboard, - onEndEditing: _endEditing, + body: SafeArea( + bottom: false, + left: false, + right: false, + child: Column( + children: [ + _buildTopPanelToggle(context), + Expanded( + child: _buildSuperEditor(context, _isKeyboardPanelVisible), ), - ), - ], + ], + ), ), ); } -} -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; + Widget _buildSuperEditor(BuildContext context, bool isKeyboardPanelVisible) { + _isKeyboardPanelVisible = isKeyboardPanelVisible; + + return SuperEditor( + focusNode: _focusNode, + editor: _editor, + softwareKeyboardController: _keyboardController, + selectionPolicies: const SuperEditorSelectionPolicies( + clearSelectionWhenEditorLosesFocus: false, + // Currently, closing the software keyboard causes the IME connection to close. + clearSelectionWhenImeConnectionCloses: false, + ), + imePolicies: const SuperEditorImePolicies( + openKeyboardOnSelectionChange: false, + ), + ); } - void _closeKeyboardAndPanel() { - setState(() { - widget.nonKeyboardEditorState.value = _InputState.closed; - _maxBottomInsets = min(_latestBottomInsets, _maxBottomInsets); - }); + Widget _buildTopPanelToggle(BuildContext context) { + return KeyboardPanelScaffold( + controller: _keyboardPanelController, + isImeConnected: _isImeConnected, + toolbarBuilder: _buildTopPanel, + keyboardPanelBuilder: _buildKeyboardPanel, + contentBuilder: (context, wantsToShowKeyboardPanel) { + return ElevatedButton( + onPressed: _keyboardPanelController.toggleToolbar, + child: Text('Toggle above-keyboard panel'), + ); + }, + ); + } - widget.onEndEditing(); + Widget _buildTopPanel(BuildContext context, bool isKeyboardPanelVisible) { + return Container( + width: double.infinity, + height: 54, + color: Colors.grey.shade100, + child: Row( + children: [ + const SizedBox(width: 24), + GestureDetector( + onTap: _endEditing, + child: const Icon(Icons.close), + ), + const Spacer(), + GestureDetector( + onTap: () => _keyboardPanelController.toggleSoftwareKeyboardWithPanel(), + child: Icon(isKeyboardPanelVisible ? Icons.keyboard : Icons.keyboard_hide), + ), + const SizedBox(width: 24), + ], + ), + ); } - @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( + Widget _buildKeyboardPanel(BuildContext context) { + return ColoredBox( + color: Theme.of(context).scaffoldBackgroundColor, + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox(width: 24), - GestureDetector( - onTap: _closeKeyboardAndPanel, - child: const Icon(Icons.close), + Text( + 'Alignment', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + Row( + children: [ + Expanded( + child: _buildKeyboardPanelButton( + onPressed: () {}, + child: Icon(Icons.format_align_left), + ), + ), + SizedBox(width: 8), + Expanded( + child: _buildKeyboardPanelButton( + onPressed: () {}, + child: Icon(Icons.format_align_center), + ), + ), + SizedBox(width: 8), + Expanded( + child: _buildKeyboardPanelButton( + onPressed: () {}, + child: Icon(Icons.format_align_right), + ), + ), + SizedBox(width: 8), + Expanded( + child: _buildKeyboardPanelButton( + onPressed: () {}, + child: Icon(Icons.format_align_justify), + ), + ), + ], + ), + SizedBox(height: 8), + Text( + 'Text Style', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + Row( + children: [ + Expanded( + child: _buildKeyboardPanelButton( + onPressed: () {}, + child: Icon(Icons.format_bold), + ), + ), + SizedBox(width: 8), + Expanded( + child: _buildKeyboardPanelButton( + onPressed: () {}, + child: Icon(Icons.format_italic), + ), + ), + SizedBox(width: 8), + Expanded( + child: _buildKeyboardPanelButton( + onPressed: () {}, + child: Icon(Icons.format_strikethrough), + ), + ), + ], + ), + SizedBox(height: 8), + Text( + 'Convertions', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + SizedBox( + width: double.infinity, + child: _buildKeyboardPanelButton( + onPressed: () {}, + child: Text('Header 1'), + ), ), - const Spacer(), - GestureDetector( - onTap: widget.keyboardState.value == _InputState.open ? widget.onCloseKeyboard : widget.onOpenKeyboard, - child: Icon(widget.keyboardState.value == _InputState.open ? Icons.keyboard_hide : Icons.keyboard), + SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: _buildKeyboardPanelButton( + onPressed: () {}, + child: Text('Header 2'), + ), + ), + SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: _buildKeyboardPanelButton( + onPressed: () {}, + child: Text('Blockquote'), + ), + ), + SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: _buildKeyboardPanelButton( + onPressed: () {}, + child: Text('Ordered List'), + ), + ), + SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: _buildKeyboardPanelButton( + onPressed: () {}, + child: Text('Unordered List'), + ), ), - const SizedBox(width: 24), ], ), ), - SizedBox( - width: double.infinity, - height: _maxBottomInsets, - child: ColoredBox( - color: Colors.grey.shade300, + ), + ); + } + + Widget _buildKeyboardPanelButton({ + required VoidCallback onPressed, + required Widget child, + }) { + return TextButton( + style: ButtonStyle( + foregroundColor: WidgetStateProperty.all(Colors.black), + backgroundColor: WidgetStateProperty.all(Colors.white), + minimumSize: WidgetStateProperty.all(Size(0, 60)), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.zero, + ), + ), + textStyle: WidgetStateProperty.all( + TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, ), ), - ], + ), + onPressed: onPressed, + child: child, ); } } - -enum _InputState { - open, - closed, -} diff --git a/super_editor/example/lib/demos/mobile_chat/demo_mobile_chat.dart b/super_editor/example/lib/demos/mobile_chat/demo_mobile_chat.dart index 32ff962a6..7fabecbf0 100644 --- a/super_editor/example/lib/demos/mobile_chat/demo_mobile_chat.dart +++ b/super_editor/example/lib/demos/mobile_chat/demo_mobile_chat.dart @@ -9,8 +9,18 @@ class MobileChatDemo extends StatefulWidget { } class _MobileChatDemoState extends State { + final FocusNode _screenFocusNode = FocusNode(); + + final FocusNode _editorFocusNode = FocusNode(); late final Editor _editor; + late final KeyboardPanelController _keyboardPanelController; + final SoftwareKeyboardController _softwareKeyboardController = SoftwareKeyboardController(); + + final _imeConnectionNotifier = ValueNotifier(false); + + _Panel? _visiblePanel; + @override void initState() { super.initState(); @@ -18,60 +28,178 @@ class _MobileChatDemoState extends State { final document = MutableDocument.empty(); final composer = MutableDocumentComposer(); _editor = createDefaultDocumentEditor(document: document, composer: composer); + + _keyboardPanelController = KeyboardPanelController(_softwareKeyboardController); + + // Initially focus the overall screen so that the software keyboard isn't immediately + // visible. + _screenFocusNode.requestFocus(); + } + + @override + void dispose() { + _imeConnectionNotifier.dispose(); + _keyboardPanelController.dispose(); + _editorFocusNode.dispose(); + _screenFocusNode.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: GestureDetector( + onTap: () { + _screenFocusNode.requestFocus(); + }, + child: Focus( + focusNode: _screenFocusNode, + child: ColoredBox(color: Colors.white), + ), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: _buildCommentEditor(), + ), + ], + ), ); } Widget _buildCommentEditor() { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.only( - topLeft: Radius.circular(24), - topRight: Radius.circular(24), + return Opacity( + opacity: 0.5, + child: KeyboardPanelScaffold( + controller: _keyboardPanelController, + isImeConnected: _imeConnectionNotifier, + toolbarBuilder: _buildKeyboardToolbar, + fallbackPanelHeight: MediaQuery.sizeOf(context).height / 3, + 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 Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(24), + topRight: Radius.circular(24), + ), + border: Border( + top: BorderSide(width: 1, color: Colors.grey), + left: BorderSide(width: 1, color: Colors.grey), + right: BorderSide(width: 1, color: Colors.grey), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.075), + blurRadius: 8, + spreadRadius: 4, + ), + ], ), - border: Border( - top: BorderSide(width: 1, color: Colors.grey), - left: BorderSide(width: 1, color: Colors.grey), - right: BorderSide(width: 1, color: Colors.grey), + padding: const EdgeInsets.only(top: 16), + child: CustomScrollView( + shrinkWrap: true, + slivers: [ + SliverPadding( + padding: const EdgeInsets.only(bottom: 24), + sliver: SuperEditor( + editor: _editor, + focusNode: _editorFocusNode, + softwareKeyboardController: _softwareKeyboardController, + shrinkWrap: true, + stylesheet: _chatStylesheet, + selectionPolicies: const SuperEditorSelectionPolicies( + clearSelectionWhenEditorLosesFocus: true, + clearSelectionWhenImeConnectionCloses: false, + ), + isImeConnected: _imeConnectionNotifier, + ), + ), + ], ), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.075), - blurRadius: 8, - spreadRadius: 4, - ), - ], - ), - padding: const EdgeInsets.only(top: 16, bottom: 24), - child: CustomScrollView( - shrinkWrap: true, - slivers: [ - SuperEditor( - editor: _editor, - shrinkWrap: true, - stylesheet: _chatStylesheet, + ); + }, + ), + ); + } + + Widget _buildKeyboardToolbar(BuildContext context, bool isKeyboardPanelVisible) { + if (!isKeyboardPanelVisible) { + _visiblePanel = null; + } + + return Row( + children: [ + Expanded( + child: Opacity( + opacity: 0.5, + child: 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), + ], ), - ], + ), ), ), ], @@ -79,17 +207,53 @@ class _MobileChatDemoState extends State { } } +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( - addRulesAfter: [ + addRulesBefore: [ StyleRule( BlockSelector.all, (doc, docNode) { return { - Styles.maxWidth: null, + Styles.maxWidth: double.infinity, Styles.padding: const CascadingPadding.symmetric(horizontal: 24), }; }, ), + ], + addRulesAfter: [ StyleRule( BlockSelector.all.first(), (doc, docNode) { @@ -108,3 +272,48 @@ final _chatStylesheet = defaultStylesheet.copyWith( ), ], ); + +Future _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"), + ), + ], + ), + ); + } +} diff --git a/super_editor/example/lib/main_super_editor_chat.dart b/super_editor/example/lib/main_super_editor_chat.dart index 701235ea3..22e26f680 100644 --- a/super_editor/example/lib/main_super_editor_chat.dart +++ b/super_editor/example/lib/main_super_editor_chat.dart @@ -26,6 +26,7 @@ void main() { runApp( MaterialApp( home: Scaffold( + resizeToAvoidBottomInset: false, body: MobileChatDemo(), ), debugShowCheckedModeBanner: false, diff --git a/super_editor/lib/src/default_editor/document_gestures_touch_android.dart b/super_editor/lib/src/default_editor/document_gestures_touch_android.dart index 59adfd504..9b7d49d6d 100644 --- a/super_editor/lib/src/default_editor/document_gestures_touch_android.dart +++ b/super_editor/lib/src/default_editor/document_gestures_touch_android.dart @@ -985,6 +985,7 @@ class _AndroidDocumentTouchInteractorState extends State 0; + } + void _onPanStart(DragStartDetails details) { // Stop waiting for a long-press to start, if a long press isn't already in-progress. _tapDownLongPressTimer?.cancel(); diff --git a/super_editor/lib/src/default_editor/document_gestures_touch_ios.dart b/super_editor/lib/src/default_editor/document_gestures_touch_ios.dart index adc421a69..dad29335f 100644 --- a/super_editor/lib/src/default_editor/document_gestures_touch_ios.dart +++ b/super_editor/lib/src/default_editor/document_gestures_touch_ios.dart @@ -695,9 +695,13 @@ class _IosDocumentTouchInteractorState extends State 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. @@ -740,6 +744,16 @@ class _IosDocumentTouchInteractorState extends State 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. diff --git a/super_editor/lib/src/default_editor/document_ime/ime_keyboard_control.dart b/super_editor/lib/src/default_editor/document_ime/ime_keyboard_control.dart index 061b06485..eefed0c62 100644 --- a/super_editor/lib/src/default_editor/document_ime/ime_keyboard_control.dart +++ b/super_editor/lib/src/default_editor/document_ime/ime_keyboard_control.dart @@ -73,6 +73,11 @@ class _SoftwareKeyboardOpenerState extends State impleme widget.imeConnection.value!.show(); } + @override + void hide() { + SystemChannels.textInput.invokeListMethod("TextInput.hide"); + } + @override void close() { editorImeLog.info("[SoftwareKeyboard] - closing IME connection."); @@ -125,6 +130,11 @@ class SoftwareKeyboardController { _delegate?.open(); } + void hide() { + assert(hasDelegate); + _delegate?.hide(); + } + /// Closes the software keyboard. void close() { assert(hasDelegate); @@ -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(); } diff --git a/super_editor/lib/src/default_editor/document_ime/supereditor_ime_interactor.dart b/super_editor/lib/src/default_editor/document_ime/supereditor_ime_interactor.dart index d49a76231..b1e6a558c 100644 --- a/super_editor/lib/src/default_editor/document_ime/supereditor_ime_interactor.dart +++ b/super_editor/lib/src/default_editor/document_ime/supereditor_ime_interactor.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; @@ -44,6 +45,7 @@ class SuperEditorImeInteractor extends StatefulWidget { this.imePolicies = const SuperEditorImePolicies(), this.imeConfiguration = const SuperEditorImeConfiguration(), this.imeOverrides, + this.isImeConnected, this.hardwareKeyboardActions = const [], required this.selectorHandlers, this.floatingCursorController, @@ -110,6 +112,12 @@ class SuperEditorImeInteractor extends StatefulWidget { /// for various IME messages. final DeltaTextInputClientDecorator? imeOverrides; + /// A (optional) notifier that's notified when the IME connection opens or closes. + /// + /// A `true` value means this interactor is connected to the platform's IME, a `false` + /// value means this interactor isn't connected to the platforms IME. + final ValueNotifier? isImeConnected; + /// All the actions that the user can execute with physical hardware /// keyboard keys. /// @@ -177,6 +185,15 @@ class SuperEditorImeInteractorState extends State impl _imeConnection.addListener(_onImeConnectionChange); _textInputConfiguration = widget.imeConfiguration.toTextInputConfiguration(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + // Synchronize the IME connection notifier with our IME connection state. We run + // this in a post-frame callback because the very first pump of the Super Editor + // widget tree won't have Super Editor connected as an IME delegate, yet. + if (widget.softwareKeyboardController != null) { + widget.isImeConnected?.value = widget.softwareKeyboardController!.isConnectedToIme; + } + }); } @override @@ -266,6 +283,7 @@ class SuperEditorImeInteractorState extends State impl if (_imeConnection.value == null) { _documentImeConnection.value = null; widget.imeOverrides?.client = null; + widget.isImeConnected?.value = false; return; } @@ -273,6 +291,8 @@ class SuperEditorImeInteractorState extends State impl _documentImeConnection.value = _documentImeClient; _reportVisualInformationToIme(); + + widget.isImeConnected?.value = true; } void _configureImeClientDecorators() { diff --git a/super_editor/lib/src/default_editor/super_editor.dart b/super_editor/lib/src/default_editor/super_editor.dart index 3a44084ae..61ddf7a43 100644 --- a/super_editor/lib/src/default_editor/super_editor.dart +++ b/super_editor/lib/src/default_editor/super_editor.dart @@ -122,6 +122,7 @@ class SuperEditor extends StatefulWidget { this.imePolicies = const SuperEditorImePolicies(), this.imeConfiguration, this.imeOverrides, + this.isImeConnected, this.keyboardActions, this.selectorHandlers, this.gestureMode, @@ -262,6 +263,12 @@ class SuperEditor extends StatefulWidget { /// behaviors for various IME messages. final DeltaTextInputClientDecorator? imeOverrides; + /// A (optional) notifier that's notified when the IME connection opens or closes. + /// + /// A `true` value means [SuperEditor] is connected to the platform's IME, a `false` + /// value means [SuperEditor] isn't connected to the platforms IME. + final ValueNotifier? isImeConnected; + /// The `SuperEditor` gesture mode, e.g., mouse or touch. final DocumentGestureMode? gestureMode; @@ -765,6 +772,7 @@ class SuperEditorState extends State { ..._keyboardActions, ], selectorHandlers: widget.selectorHandlers ?? defaultEditorSelectorHandlers, + isImeConnected: widget.isImeConnected, child: child, ); } diff --git a/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart new file mode 100644 index 000000000..8974909b6 --- /dev/null +++ b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart @@ -0,0 +1,746 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/src/default_editor/document_ime/document_input_ime.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; + +/// Scaffold that displays the given [contentBuilder], while also (optionally) displaying +/// a toolbar docked to the top of the software keyboard, and/or a panel that appears +/// instead of the software keyboard. +/// +/// A typical use case for the keyboard panel is a chat application switching between the +/// software keyboard and an emoji panel. +/// +/// To correctly use this scaffold, you must place a [KeyboardScaffoldSafeArea] higher in +/// the widget tree to adjust the padding so that the content is above the keyboard panel +/// and software keyboard. The [KeyboardScaffoldSafeArea] can go anywhere higher in the tree, +/// so long as the [KeyboardScaffoldSafeArea] takes up the entire screen. +/// +/// The widget returned by [toolbarBuilder] is positioned above the keyboard panel, when +/// visible, or above the software keyboard, when visible. If neither the keyboard panel nor +/// the software keyboard are visible, the widget is positioned at the bottom of the screen. +/// +/// The widget returned by [keyboardPanelBuilder] is positioned at the bottom of the screen, +/// with its height constrained to be equal to the software keyboard height. +/// +/// The widget returned by [contentBuilder] is positioned above the above-keyboard panel, +/// using all the remaining height. +/// +/// Use the [controller] to show/hide the keyboard panel and software keyboard. +/// +/// If there is a [Scaffold] in your widget tree, it must have `resizeToAvoidBottomInset` +/// set to `false`, otherwise we can't get the software keyboard height to size the keyboard +/// panel. If `resizeToAvoidBottomInset` is set to `true`, the panel won't be displayed. +class KeyboardPanelScaffold extends StatefulWidget { + const KeyboardPanelScaffold({ + super.key, + required this.controller, + required this.isImeConnected, + required this.toolbarBuilder, + required this.keyboardPanelBuilder, + this.fallbackPanelHeight = 250, + required this.contentBuilder, + }); + + /// Controls the visibility of the keyboard toolbar, keyboard panel, and software keyboard. + final KeyboardPanelController controller; + + /// A [ValueListenable] that should notify this [KeyboardPanelScaffold] when the IME connects + /// and disconnects. + /// + /// This signal is used to automatically close any open panel when the IME disconnects. + final ValueListenable isImeConnected; + + /// Builds the toolbar that's docked to the top of the software keyboard area. + final Widget Function(BuildContext context, bool isKeyboardPanelVisible) toolbarBuilder; + + /// Builds the keyboard panel that's displayed in place of the software keyboard. + final WidgetBuilder keyboardPanelBuilder; + + /// The height of the keyboard panel in situations where no software keyboard is + /// present, e.g., on a tablet when using a physical keyboard, or when using a floating + /// software keyboard. + final double fallbackPanelHeight; + + /// Builds the regular widget subtree beneath this widget. + /// + /// This is the content that this widget "wraps". Sometimes this content might be + /// a whole screen of content, or other times this content might be a single widget + /// like a text field or an editor. + final Widget Function(BuildContext context, bool isKeyboardPanelVisible) contentBuilder; + + @override + State createState() => _KeyboardPanelScaffoldState(); +} + +class _KeyboardPanelScaffoldState extends State + with SingleTickerProviderStateMixin + implements KeyboardPanelScaffoldDelegate { + /// The maximum bottom insets that have been observed since the keyboard started expanding. + /// + /// This is reset when both the software keyboard and the keyboard panel are closed. + double _maxBottomInsets = 0.0; + + /// The current height of the keyboard. + /// + /// This is used to size the keyboard panel and to position the top panel above the keyboard. + /// + /// This value respects the following rules: + /// + /// - When the software keyboard is collapsing and the user wants to show the keyboard panel, + /// this value is equal to the latest [_maxBottomInsets] observed while the keyboard was visible. + /// + /// - When the software keyboard is closed and the user closes the keyboard panel, this value + /// is animated from the latest [_maxBottomInsets] to zero. + /// + /// - Otherwise, it is equal to [_maxBottomInsets]. + final ValueNotifier _keyboardHeight = ValueNotifier(0.0); + + /// The latest view insets obtained from the enclosing `MediaQuery`. + /// + /// It's used to detect if the software keyboard is closed, open, collapsing or expanding. + EdgeInsets _latestViewInsets = EdgeInsets.zero; + + /// Whether or not we believe that the keyboard is currently open (or opening). + bool _isKeyboardOpen = false; + + /// Controls the exit animation of the keyboard panel when the software keyboard is closed. + /// + /// When we close the software keyboard, the `_keyboardPanelHeight` is adjusted automatically + /// while the insets are collapsing. If the software keyboard is closed and we want to hide + /// the keyboard panel, we need to animated it ourselves. + late final AnimationController _panelExitAnimation; + + /// Shows/hides the [OverlayPortal] containing the keyboard panel and above-keyboard panel. + final OverlayPortalController _overlayPortalController = OverlayPortalController(); + + bool get _wantsToShowToolbar => + widget.controller.toolbarVisibility == KeyboardToolbarVisibility.visible || + (widget.controller.toolbarVisibility == KeyboardToolbarVisibility.auto && widget.isImeConnected.value); + + final _toolbarKey = GlobalKey(); + + SoftwareKeyboardController? _softwareKeyboardController; + + @override + void initState() { + super.initState(); + assert(() { + final scaffold = Scaffold.maybeOf(context); + if (scaffold != null && scaffold.widget.resizeToAvoidBottomInset != false) { + throw FlutterError( + 'KeyboardPanelScaffold is placed inside a Scaffold with resizeToAvoidBottomInset set to true.\n' + 'This will produce incorrect results. Set resizeToAvoidBottomInset to false.', + ); + } + return true; + }()); + + _panelExitAnimation = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 250), + ); + _panelExitAnimation.addListener(_updatePanelForExitAnimation); + + widget.controller.attach(this); + + widget.isImeConnected.addListener(_onImeConnectionChange); + + _overlayPortalController.show(); + onNextFrame((_) { + // Do initial safe area report to our ancestor keyboard safe area widget, + // after we've added our UI to the overlay portal. + _updateSafeArea(); + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _updateKeyboardHeightForCurrentViewInsets(); + } + + @override + void didUpdateWidget(KeyboardPanelScaffold oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + oldWidget.controller.detach(); + widget.controller.attach(this); + } + + if (widget.isImeConnected != oldWidget.isImeConnected) { + oldWidget.isImeConnected.removeListener(_onImeConnectionChange); + widget.isImeConnected.addListener(_onImeConnectionChange); + } + } + + @override + void reassemble() { + super.reassemble(); + + // In case we made a code change during development that impacts the + // visibility of the toolbar. Re-calculate the ancestor keyboard safe area. + _updateSafeArea(); + } + + @override + void dispose() { + widget.isImeConnected.removeListener(_onImeConnectionChange); + + widget.controller.detach(); + + _panelExitAnimation.removeListener(_updatePanelForExitAnimation); + _panelExitAnimation.dispose(); + + if (_overlayPortalController.isShowing) { + // WARNING: We can only call `hide()` if `isShowing` is `true`. If we blindly + // call `hide()` then we'll get a z-index error reported. Flutter should clean + // that up internally, but until then (written Oct 14, 2024) we guard it here. + _overlayPortalController.hide(); + } + + super.dispose(); + } + + @override + void onAttached(SoftwareKeyboardController softwareKeyboardController) { + _softwareKeyboardController = softwareKeyboardController; + } + + @override + void onDetached() { + _softwareKeyboardController = null; + } + + void _onImeConnectionChange() { + final isImeConnected = widget.isImeConnected.value; + if (isImeConnected) { + return; + } + + // The IME isn't connected. Ensure the panel is closed. + widget.controller.closeKeyboardAndPanel(); + } + + /// Whether the toolbar should be displayed, anchored to the top of the keyboard area. + @override + KeyboardToolbarVisibility get toolbarVisibility => _toolbarVisibility; + KeyboardToolbarVisibility _toolbarVisibility = KeyboardToolbarVisibility.auto; + @override + set toolbarVisibility(KeyboardToolbarVisibility value) { + if (value == _toolbarVisibility) { + return; + } + + _toolbarVisibility = value; + switch (value) { + case KeyboardToolbarVisibility.visible: + showToolbar(); + case KeyboardToolbarVisibility.hidden: + hideToolbar(); + case KeyboardToolbarVisibility.auto: + _wantsToShowSoftwareKeyboard || _wantsToShowKeyboardPanel // + ? showToolbar() + : hideToolbar(); + } + } + + bool _isToolbarVisible = false; + + /// Shows the toolbar, if it's hidden, or hides the toolbar, if it's visible. + @override + void toggleToolbar() { + if (_isToolbarVisible) { + hideToolbar(); + } else { + showToolbar(); + } + } + + /// Shows the toolbar that's mounted to the top of the keyboard area. + @override + void showToolbar() { + setState(() { + _toolbarVisibility = KeyboardToolbarVisibility.visible; + _isToolbarVisible = true; + }); + } + + /// Hides the toolbar that's mounted to the top of the keyboard area. + @override + void hideToolbar() { + setState(() { + _toolbarVisibility = KeyboardToolbarVisibility.hidden; + _isToolbarVisible = false; + }); + } + + /// Whether the software keyboard should be displayed, instead of the keyboard panel. + bool get wantsToShowSoftwareKeyboard => _wantsToShowSoftwareKeyboard; + bool _wantsToShowSoftwareKeyboard = false; + + /// Opens the keyboard panel if the keyboard is open, or opens the keyboard + /// if the keyboard panel is open. + @override + void toggleSoftwareKeyboardWithPanel() { + if (_wantsToShowKeyboardPanel) { + showSoftwareKeyboard(); + } else { + showKeyboardPanel(); + } + } + + /// Shows the software keyboard, if it's hidden. + @override + void showSoftwareKeyboard() { + setState(() { + _wantsToShowKeyboardPanel = false; + _wantsToShowSoftwareKeyboard = true; + _softwareKeyboardController!.open(); + }); + } + + /// Hides (doesn't close) the software keyboard, if it's open. + @override + void hideSoftwareKeyboard() { + setState(() { + _wantsToShowSoftwareKeyboard = false; + _softwareKeyboardController!.hide(); + }); + + _maybeAnimatePanelClosed(); + } + + /// Whether a keyboard panel should be displayed instead of the software keyboard. + bool get wantsToShowKeyboardPanel => _wantsToShowKeyboardPanel; + bool _wantsToShowKeyboardPanel = false; + + /// Shows the keyboard panel, if it's closed, and hides (doesn't close) the + /// software keyboard, if it's open. + @override + void showKeyboardPanel() { + setState(() { + _wantsToShowKeyboardPanel = true; + _wantsToShowSoftwareKeyboard = false; + _softwareKeyboardController!.hide(); + }); + } + + /// Hides the keyboard panel, if it's open. + @override + void hideKeyboardPanel() { + setState(() { + _wantsToShowKeyboardPanel = false; + }); + } + + /// Closes the software keyboard if it's open, or closes the keyboard panel if + /// it's open, and fully closes the keyboard (IME) connection. + @override + void closeKeyboardAndPanel() { + setState(() { + _wantsToShowKeyboardPanel = false; + _wantsToShowSoftwareKeyboard = false; + _softwareKeyboardController!.close(); + }); + + _maybeAnimatePanelClosed(); + } + + void _maybeAnimatePanelClosed() { + if (_wantsToShowKeyboardPanel || _wantsToShowSoftwareKeyboard || _latestViewInsets.bottom != 0.0) { + return; + } + + // The user wants to close both the software keyboard and the keyboard panel, + // but the software keyboard is already closed. Animate the keyboard panel height + // down to zero. + _panelExitAnimation.reverse(from: 1.0); + } + + // Updates our local cache of the current bottom window insets, which we assume reflects + // the current software keyboard height. + void _updateKeyboardHeightForCurrentViewInsets() { + final newInsets = MediaQuery.of(context).viewInsets; + final newBottomInset = newInsets.bottom; + final isKeyboardCollapsing = newBottomInset < _latestViewInsets.bottom; + + if (_isKeyboardOpen && isKeyboardCollapsing) { + // The keyboard went from open to closed. Update our cached state. + _isKeyboardOpen = false; + } else if (!_isKeyboardOpen && !isKeyboardCollapsing) { + // The keyboard went from closed to open. If there's an open panel, close it. + _isKeyboardOpen = true; + widget.controller.hideKeyboardPanel(); + } + + _latestViewInsets = newInsets; + + if (newBottomInset > _maxBottomInsets) { + // The keyboard is expanding. + _maxBottomInsets = newBottomInset; + _keyboardHeight.value = _maxBottomInsets; + onNextFrame((ts) => _updateSafeArea()); + return; + } + + if (isKeyboardCollapsing && !_wantsToShowKeyboardPanel) { + // The keyboard is collapsing and we don't want the keyboard panel to be visible. + // Follow the keyboard back down. + _maxBottomInsets = newBottomInset; + _keyboardHeight.value = _maxBottomInsets; + onNextFrame((ts) => _updateSafeArea()); + return; + } + } + + /// Animates the panel height when the software keyboard is closed and the user wants + /// to close the keyboard panel. + void _updatePanelForExitAnimation() { + setState(() { + _keyboardHeight.value = _maxBottomInsets * Curves.easeInQuad.transform(_panelExitAnimation.value); + onNextFrame((ts) => _updateSafeArea()); + if (_panelExitAnimation.status == AnimationStatus.dismissed) { + // The panel has been fully collapsed. Reset the max known bottom insets. + _maxBottomInsets = 0.0; + } + }); + } + + /// Update the bottom insets of the enclosing [KeyboardScaffoldSafeArea]. + void _updateSafeArea() { + final keyboardSafeAreaData = KeyboardScaffoldSafeArea.maybeOf(context); + if (keyboardSafeAreaData == null) { + return; + } + + final toolbarSize = (_toolbarKey.currentContext?.findRenderObject() as RenderBox?)?.size; + keyboardSafeAreaData.geometry = keyboardSafeAreaData.geometry.copyWith( + bottomInsets: _wantsToShowKeyboardPanel // + ? _keyboardPanelHeight + (toolbarSize?.height ?? 0) + : _keyboardHeight.value + (toolbarSize?.height ?? 0), + ); + } + + double get _keyboardPanelHeight { + return _wantsToShowKeyboardPanel // + ? _keyboardHeight.value < 100 // + ? widget.fallbackPanelHeight + : _keyboardHeight.value + : 0.0; + } + + @override + Widget build(BuildContext context) { + final wantsToShowKeyboardPanel = _wantsToShowKeyboardPanel || + // The keyboard panel should be kept visible while the software keyboard is expanding + // and the keyboard panel was previously visible. Otherwise, there will be an empty + // region between the top of the software keyboard and the bottom of the above-keyboard panel. + (_wantsToShowSoftwareKeyboard && _latestViewInsets.bottom < _keyboardHeight.value); + + final double fakeKeyboardHeight = _wantsToShowKeyboardPanel // + ? _keyboardHeight.value < 100 // + ? widget.fallbackPanelHeight + : 0.0 + : 0.0; + + return OverlayPortal( + controller: _overlayPortalController, + overlayChildBuilder: (context) { + return ValueListenableBuilder( + valueListenable: _keyboardHeight, + builder: (context, currentHeight, child) { + if (!_wantsToShowToolbar && !wantsToShowKeyboardPanel) { + return const SizedBox.shrink(); + } + + onNextFrame((_) { + // Ensure that our latest keyboard height/panel height calculations are + // accounted for in the ancestor safe area after this layout pass. + _updateSafeArea(); + }); + + return Positioned( + bottom: 0, + left: 0, + right: 0, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_wantsToShowToolbar) + KeyedSubtree( + key: _toolbarKey, + child: widget.toolbarBuilder( + context, + _wantsToShowKeyboardPanel, + ), + ), + SizedBox( + height: !_wantsToShowKeyboardPanel || _keyboardHeight.value > 100 + ? _keyboardHeight.value + : fakeKeyboardHeight, + child: wantsToShowKeyboardPanel ? widget.keyboardPanelBuilder(context) : null, + ), + ], + ), + ); + }, + ); + }, + child: Padding( + // padding: EdgeInsets.only(bottom: fakeKeyboardHeight), + padding: EdgeInsets.zero, + child: widget.contentBuilder( + context, + _wantsToShowKeyboardPanel, + ), + ), + ); + } +} + +/// Shows and hides the keyboard panel and software keyboard. +class KeyboardPanelController { + KeyboardPanelController( + this._softwareKeyboardController, + ); + + void dispose() { + detach(); + } + + final SoftwareKeyboardController _softwareKeyboardController; + + KeyboardPanelScaffoldDelegate? _delegate; + + /// Whether this controller is currently attached to a delegate that + /// knows how to show a toolbar, and open/close the software keyboard + /// and keyboard panel. + bool get hasDelegate => _delegate != null; + + /// Attaches this controller to a delegate that knows how to show a toolbar, open and + /// close the software keyboard, and the keyboard panel. + void attach(KeyboardPanelScaffoldDelegate delegate) { + editorImeLog.finer("[KeyboardPanelController] - Attaching to delegate: $delegate"); + _delegate = delegate; + _delegate!.onAttached(_softwareKeyboardController); + } + + /// Detaches this controller from its delegate. + /// + /// This controller can't open or close the software keyboard, or keyboard panel, while + /// detached from a delegate that knows how to make that happen. + void detach() { + editorImeLog.finer("[KeyboardPanelController] - Detaching from delegate: $_delegate"); + _delegate?.onDetached(); + _delegate = null; + } + + /// Whether the toolbar should be displayed, anchored to the top of the keyboard area. + KeyboardToolbarVisibility get toolbarVisibility => _delegate?.toolbarVisibility ?? KeyboardToolbarVisibility.hidden; + set toolbarVisibility(KeyboardToolbarVisibility value) => _delegate?.toolbarVisibility = value; + + /// Shows the toolbar, if it's hidden, or hides the toolbar, if it's visible. + void toggleToolbar() => _delegate?.toggleToolbar(); + + /// Shows the toolbar that's mounted to the top of the keyboard area. + void showToolbar() => _delegate?.showToolbar(); + + /// Hides the toolbar that's mounted to the top of the keyboard area. + void hideToolbar() => _delegate?.hideToolbar(); + + /// Opens the keyboard panel if the keyboard is open, or opens the keyboard + /// if the keyboard panel is open. + void toggleSoftwareKeyboardWithPanel() => _delegate?.toggleSoftwareKeyboardWithPanel(); + + /// Shows the software keyboard, if it's hidden. + void showSoftwareKeyboard() => _delegate?.showSoftwareKeyboard(); + + /// Hides (doesn't close) the software keyboard, if it's open. + void hideSoftwareKeyboard() => _delegate?.hideSoftwareKeyboard(); + + /// Shows the keyboard panel, if it's closed, and hides (doesn't close) the + /// software keyboard, if it's open. + void showKeyboardPanel() => _delegate?.showKeyboardPanel(); + + /// Hides the keyboard panel, if it's open. + void hideKeyboardPanel() => _delegate?.hideKeyboardPanel(); + + /// Closes the software keyboard if it's open, or closes the keyboard panel if + /// it's open, and fully closes the keyboard (IME) connection. + void closeKeyboardAndPanel() => _delegate?.closeKeyboardAndPanel(); +} + +abstract interface class KeyboardPanelScaffoldDelegate { + /// Called on this delegate by the [KeyboardPanelController] when the controller + /// attaches to the delegate. + /// + /// [onAttached] is used to pass critical dependencies from the controller to + /// the delegate. + void onAttached(SoftwareKeyboardController softwareKeyboardController); + + /// Called on this delegate by the [KeyboardPanelController] when the controller + /// detaches from the delegate. + /// + /// Implementers should release any resources created/stored in [onAttached]. + void onDetached(); + + /// The visibility policy for the toolbar that's docked to the top of the software keyboard. + KeyboardToolbarVisibility get toolbarVisibility; + set toolbarVisibility(KeyboardToolbarVisibility value); + + /// Shows the toolbar, if it's hidden, or hides the toolbar, if it's visible. + void toggleToolbar(); + + /// Shows the toolbar that's mounted to the top of the keyboard area. + void showToolbar(); + + /// Hides the toolbar that's mounted to the top of the keyboard area. + void hideToolbar(); + + /// Opens the keyboard panel if the keyboard is open, or opens the keyboard + /// if the keyboard panel is open. + void toggleSoftwareKeyboardWithPanel(); + + /// Shows the software keyboard, if it's hidden. + void showSoftwareKeyboard(); + + /// Hides (doesn't close) the software keyboard, if it's open. + void hideSoftwareKeyboard(); + + /// Shows the keyboard panel, if it's closed, and hides (doesn't close) the + /// software keyboard, if it's open. + void showKeyboardPanel(); + + /// Hides the keyboard panel, if it's open. + void hideKeyboardPanel(); + + /// Closes the software keyboard if it's open, or closes the keyboard panel if + /// it's open, and fully closes the keyboard (IME) connection. + void closeKeyboardAndPanel(); +} + +enum KeyboardToolbarVisibility { + /// The toolbar should be hidden. + hidden, + + /// The toolbar should be visible. + visible, + + /// The toolbar should be visible only when the software keyboard is open, + /// or the keyboard panel is open. + auto, +} + +/// Applies padding to the bottom of the child to avoid the software keyboard and +/// the above-keyboard toolbar. +/// +/// [KeyboardScaffoldSafeArea] is separate from [KeyboardPanelScaffold] because any +/// widget might want to wrap itself with a [KeyboardPanelScaffold], but the +/// [KeyboardScaffoldSafeArea] needs to be added somewhere in the widget tree that +/// controls the size of the whole screen. +/// +/// For example, imagine a social app, like Twitter, that has a text field at the +/// top of the screen to write a post, followed by a social feed below it. The +/// text field would wrap itself with a [KeyboardPanelScaffold] to add a toolbar +/// to the keyboard, but the [KeyboardScaffoldSafeArea] would need to go higher +/// up the widget tree to surround the whole screen. +/// +/// The padding in [KeyboardScaffoldSafeArea] is set by a descendant [KeyboardPanelScaffold] +/// in the widget tree. +class KeyboardScaffoldSafeArea extends StatefulWidget { + static KeyboardScaffoldSafeAreaMutator of(BuildContext context) { + return maybeOf(context)!; + } + + static KeyboardScaffoldSafeAreaMutator? maybeOf(BuildContext context) { + context.dependOnInheritedWidgetOfExactType<_InheritedKeyboardScaffoldSafeArea>(); + return context.findAncestorStateOfType<_KeyboardScaffoldSafeAreaState>(); + } + + const KeyboardScaffoldSafeArea({ + super.key, + required this.child, + }); + + final Widget child; + + @override + State createState() => _KeyboardScaffoldSafeAreaState(); +} + +class _KeyboardScaffoldSafeAreaState extends State + implements KeyboardScaffoldSafeAreaMutator { + KeyboardSafeAreaGeometry _keyboardSafeAreaData = KeyboardSafeAreaGeometry(); + + @override + KeyboardSafeAreaGeometry get geometry => _keyboardSafeAreaData; + + @override + set geometry(KeyboardSafeAreaGeometry geometry) { + if (geometry == _keyboardSafeAreaData) { + return; + } + + setState(() { + _keyboardSafeAreaData = geometry; + }); + } + + @override + Widget build(BuildContext context) { + return _InheritedKeyboardScaffoldSafeArea( + keyboardSafeAreaData: _keyboardSafeAreaData, + child: Padding( + padding: EdgeInsets.only(bottom: _keyboardSafeAreaData.bottomInsets), + child: widget.child, + ), + ); + } +} + +abstract interface class KeyboardScaffoldSafeAreaMutator { + KeyboardSafeAreaGeometry get geometry; + set geometry(KeyboardSafeAreaGeometry geometry); +} + +class _InheritedKeyboardScaffoldSafeArea extends InheritedWidget { + const _InheritedKeyboardScaffoldSafeArea({ + required this.keyboardSafeAreaData, + required super.child, + }); + + final KeyboardSafeAreaGeometry keyboardSafeAreaData; + + @override + bool updateShouldNotify(covariant _InheritedKeyboardScaffoldSafeArea oldWidget) { + return oldWidget.keyboardSafeAreaData != keyboardSafeAreaData; + } +} + +/// Insets applied by a [KeyboardPanelScaffold] to an ancestor [KeyboardScaffoldSafeArea] +/// to deal with the presence or absence of the software keyboard. +class KeyboardSafeAreaGeometry { + const KeyboardSafeAreaGeometry({ + this.bottomInsets = 0, + }); + + final double bottomInsets; + + KeyboardSafeAreaGeometry copyWith({ + double? bottomInsets, + }) { + return KeyboardSafeAreaGeometry( + bottomInsets: bottomInsets ?? this.bottomInsets, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is KeyboardSafeAreaGeometry && runtimeType == other.runtimeType && bottomInsets == other.bottomInsets; + + @override + int get hashCode => bottomInsets.hashCode; +} diff --git a/super_editor/lib/super_editor.dart b/super_editor/lib/super_editor.dart index 26f6f9dc0..4386ca1dd 100644 --- a/super_editor/lib/super_editor.dart +++ b/super_editor/lib/super_editor.dart @@ -90,6 +90,7 @@ export 'src/infrastructure/text_input.dart'; export 'src/infrastructure/popovers.dart'; export 'src/infrastructure/selectable_list.dart'; export 'src/infrastructure/actions.dart'; +export 'src/infrastructure/keyboard_panel_scaffold.dart'; // Super Reader export 'src/super_reader/read_only_document_android_touch_interactor.dart'; diff --git a/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart b/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart new file mode 100644 index 000000000..071e069ee --- /dev/null +++ b/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart @@ -0,0 +1,628 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_robot.dart'; +import 'package:super_editor/super_editor.dart'; + +import '../super_editor/supereditor_test_tools.dart'; + +void main() { + group('Keyboard panel scaffold >', () { + group('phones >', () { + testWidgetsOnMobilePhone('does not show toolbar upon initialization', (tester) async { + await _pumpTestApp(tester); + + // Ensure the keyboard panel is not visible. + expect(find.byKey(_keyboardPanelKey), findsNothing); + }); + + testWidgetsOnMobilePhone('shows toolbar at the bottom when there is no keyboard', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestApp( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + ); + + // Request to show the above-keyboard toolbar. + controller.showToolbar(); + await tester.pump(); + + // Ensure the above-keyboard toolbar sits at the bottom of the screen. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + equals(tester.getSize(find.byType(MaterialApp)).height), + ); + }); + + testWidgetsOnMobilePhone('does not show keyboard panel upon keyboard appearance', (tester) async { + await _pumpTestApp(tester); + + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Ensure the keyboard panel is not visible. + expect(find.byKey(_keyboardPanelKey), findsNothing); + }); + + testWidgetsOnMobilePhone('shows keyboard toolbar above the keyboard', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestApp( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + ); + + // Request to show the above-keyboard panel. + controller.showToolbar(); + await tester.pump(); + + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Ensure the above-keyboard panel sits above the software keyboard. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + equals(tester.getSize(find.byType(MaterialApp)).height - _expandedPhoneKeyboardHeight), + ); + }); + + testWidgetsOnMobilePhone( + 'shows keyboard toolbar above the keyboard when toggling panels and showing the keyboard', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestApp( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + ); + + // Request to show the above-keyboard panel. + controller.showToolbar(); + await tester.pump(); + + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Request to show the keyboard panel. + controller.showKeyboardPanel(); + await tester.pumpAndSettle(); + + // Hide both the keyboard panel and the software keyboard. + controller.closeKeyboardAndPanel(); + await tester.pumpAndSettle(); + + // Place the caret at the beginning of the document to show the software keyboard again. + await tester.placeCaretInParagraph('1', 0); + + // Ensure the top panel sits above the keyboard. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + equals(tester.getSize(find.byType(MaterialApp)).height - _expandedPhoneKeyboardHeight), + ); + }); + + testWidgetsOnMobilePhone('shows keyboard panel upon request', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestApp( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + ); + + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Request to show the keyboard panel. + controller.showKeyboardPanel(); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is visible. + expect(find.byKey(_keyboardPanelKey), findsOneWidget); + }); + + testWidgetsOnMobilePhone('displays panel with the same height as the keyboard', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestApp( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + ); + + // Request to show the above-keyboard panel. + controller.showToolbar(); + await tester.pump(); + + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Request to show the keyboard panel and let the entrance animation run. + controller.showKeyboardPanel(); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel has the same size as the software keyboard. + expect( + tester.getSize(find.byKey(_keyboardPanelKey)).height, + equals(_expandedPhoneKeyboardHeight), + ); + + // Ensure the above-keyboard panel sits immediately above the keyboard panel. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + equals(tester.getSize(find.byType(MaterialApp)).height - _expandedPhoneKeyboardHeight), + ); + }); + + testWidgetsOnMobilePhone('hides the panel when toggling the keyboard', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestApp( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + ); + + // Request to show the above-keyboard panel. + controller.showToolbar(); + await tester.pump(); + + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Request to show the keyboard panel and let the entrance animation run. + controller.showKeyboardPanel(); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is visible. + expect(find.byKey(_keyboardPanelKey), findsOneWidget); + + // Hide the keyboard panel and show the software keyboard. + controller.toggleSoftwareKeyboardWithPanel(); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is not visible. + expect(find.byKey(_keyboardPanelKey), findsNothing); + + // Ensure the above-keyboard panel sits immediately above the keyboard. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + equals(tester.getSize(find.byType(MaterialApp)).height - _expandedPhoneKeyboardHeight), + ); + }); + + testWidgetsOnMobilePhone('hides the panel upon request', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestApp( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + ); + + // Request to show the above-keyboard panel. + controller.showToolbar(); + await tester.pump(); + + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Request to show the keyboard panel and let the entrance animation run. + controller.showKeyboardPanel(); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is visible. + expect(find.byKey(_keyboardPanelKey), findsOneWidget); + + controller.closeKeyboardAndPanel(); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is not visible. + expect(find.byKey(_keyboardPanelKey), findsNothing); + + // Ensure the above-keyboard panel sits at the bottom of the screen. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + equals(tester.getSize(find.byType(MaterialApp)).height), + ); + }); + + testWidgetsOnMobilePhone('hides the panel when IME connection closes', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestApp( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + ); + + // Request to show the keyboard toolbar. + controller.showToolbar(); + await tester.pump(); + + // Place the caret at the beginning of the document to open the IME connection. + await tester.placeCaretInParagraph('1', 0); + + // Request to show the keyboard panel and let the entrance animation run. + controller.showKeyboardPanel(); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is visible. + expect(find.byKey(_keyboardPanelKey), findsOneWidget); + + // Close the IME connection. + softwareKeyboardController.close(); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is not visible. + expect(find.byKey(_keyboardPanelKey), findsNothing); + }); + + testWidgetsOnMobilePhone('shows toolbar at the bottom after closing the panel and the keyboard', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestApp( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + ); + + // Request to show the above-keyboard toolbar. + controller.showToolbar(); + await tester.pump(); + + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Request to show the keyboard panel and let the entrance animation run. + controller.showKeyboardPanel(); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is visible. + expect(find.byKey(_keyboardPanelKey), findsOneWidget); + + // Hide the keyboard panel and the software keyboard. + controller.closeKeyboardAndPanel(); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is not visible. + expect(find.byKey(_keyboardPanelKey), findsNothing); + + // Ensure the above-keyboard toolbar sits at the bottom of the screen. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + tester.getSize(find.byType(MaterialApp)).height, + ); + }); + }); + + group('iPad >', () { + testWidgetsOnIPad('shows panel when keyboard is docked', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestApp( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + simulatedKeyboardHeight: _expandedIPadKeyboardHeight, + ); + + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Request to show the keyboard panel and let the entrance animation run. + controller.showKeyboardPanel(); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is visible. + expect(find.byKey(_keyboardPanelKey), findsOneWidget); + + final panelSize = tester.getSize(find.byKey(_keyboardPanelKey)); + expect(panelSize.height, _expandedIPadKeyboardHeight); + }); + + testWidgetsOnIPad('shows and closes panel when keyboard is floating or minimized', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestApp( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + simulatedKeyboardHeight: _minimizedIPadKeyboardHeight, + ); + + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Ensure the toolbar is above the minimized keyboard area. + final screenHeight = tester.view.physicalSize.height / tester.view.devicePixelRatio; + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + screenHeight - _minimizedIPadKeyboardHeight, + ); + + // Request to show the keyboard panel and let the entrance animation run. + controller.showKeyboardPanel(); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is visible and positioned at the bottom of the screen. + expect(find.byKey(_keyboardPanelKey), findsOneWidget); + + final panelSize = tester.getSize(find.byKey(_keyboardPanelKey)); + expect(panelSize.height, _keyboardPanelHeightForTabletWithMinimizedKeyboard); + + expect( + tester.getBottomLeft(find.byKey(_keyboardPanelKey)).dy, + screenHeight, + ); + + // Ensure the toolbar is above the panel. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + screenHeight - _keyboardPanelHeightForTabletWithMinimizedKeyboard, + ); + + // Request to hide the keyboard panel. + controller.hideKeyboardPanel(); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is gone. + expect(find.byKey(_keyboardPanelKey), findsNothing); + + // Ensure the toolbar is above the minimized keyboard area. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + screenHeight - _minimizedIPadKeyboardHeight, + ); + }); + }); + + group('Android tablets >', () { + testWidgetsOnIPad('shows panel when keyboard is docked', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestApp( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + simulatedKeyboardHeight: _expandedAndroidTabletKeyboardHeight, + ); + + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Request to show the keyboard panel and let the entrance animation run. + controller.showKeyboardPanel(); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is visible. + expect(find.byKey(_keyboardPanelKey), findsOneWidget); + + final panelSize = tester.getSize(find.byKey(_keyboardPanelKey)); + expect(panelSize.height, _expandedAndroidTabletKeyboardHeight); + }); + + testWidgetsOnAndroidTablet('shows panel when keyboard is floating or minimized', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestApp( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + simulatedKeyboardHeight: _minimizedAndroidTabletKeyboardHeight, + ); + + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Ensure the toolbar is above the minimized keyboard area. + final screenHeight = tester.view.physicalSize.height / tester.view.devicePixelRatio; + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + screenHeight - _minimizedAndroidTabletKeyboardHeight, + ); + + // Request to show the keyboard panel and let the entrance animation run. + controller.showKeyboardPanel(); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is visible and positioned at the bottom of the screen. + expect(find.byKey(_keyboardPanelKey), findsOneWidget); + + final panelSize = tester.getSize(find.byKey(_keyboardPanelKey)); + expect(panelSize.height, _keyboardPanelHeightForTabletWithMinimizedKeyboard); + + expect( + tester.getBottomLeft(find.byKey(_keyboardPanelKey)).dy, + screenHeight, + ); + + // Ensure the toolbar is above the panel. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + screenHeight - _keyboardPanelHeightForTabletWithMinimizedKeyboard, + ); + + // Request to hide the keyboard panel. + controller.hideKeyboardPanel(); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is gone. + expect(find.byKey(_keyboardPanelKey), findsNothing); + + // Ensure the toolbar is above the minimized keyboard area. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + screenHeight - _minimizedAndroidTabletKeyboardHeight, + ); + }); + }); + }); +} + +/// Pumps a tree that displays a panel at the software keyboard position. +/// +/// Simulates the software keyboard appearance and disappearance by animating +/// the `MediaQuery` view insets when the app communicates with the IME to show/hide +/// the software keyboard. +Future _pumpTestApp( + WidgetTester tester, { + KeyboardPanelController? controller, + SoftwareKeyboardController? softwareKeyboardController, + ValueNotifier? isImeConnected, + double simulatedKeyboardHeight = _expandedPhoneKeyboardHeight, +}) async { + final keyboardController = softwareKeyboardController ?? SoftwareKeyboardController(); + final keyboardPanelController = controller ?? KeyboardPanelController(keyboardController); + final imeConnectionNotifier = isImeConnected ?? ValueNotifier(false); + + await tester // + .createDocument() + .withLongDoc() + .withSoftwareKeyboardController(keyboardController) + .withImeConnectionNotifier(imeConnectionNotifier) + .simulateSoftwareKeyboardInsets( + true, + simulatedKeyboardHeight: simulatedKeyboardHeight, + ) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + resizeToAvoidBottomInset: false, + body: Builder(builder: (context) { + return KeyboardPanelScaffold( + controller: keyboardPanelController, + isImeConnected: imeConnectionNotifier, + contentBuilder: (context, isKeyboardPanelVisible) => superEditor, + toolbarBuilder: (context, isKeyboardPanelVisible) => Container( + key: _aboveKeyboardToolbarKey, + height: 54, + color: Colors.blue, + ), + keyboardPanelBuilder: (context) => const SizedBox.expand( + child: ColoredBox( + key: _keyboardPanelKey, + color: Colors.red, + ), + ), + ); + }), + ), + ), + ) + .pump(); +} + +void testWidgetsOnMobilePhone( + String description, + WidgetTesterCallback test, { + bool skip = false, + TestVariant variant = const DefaultTestVariant(), +}) { + testWidgetsOnMobile( + description, + (WidgetTester tester) async { + tester.view + ..physicalSize = const Size(1179, 2556) + ..devicePixelRatio = 3.0; + + addTearDown(() { + tester.view.reset(); + }); + + await test(tester); + }, + skip: skip, + variant: variant, + ); +} + +// TODO: we want the iPad and Android tablet to be configurable in some way for +// minimized/floating keyboard vs docked keyboard. + +void testWidgetsOnIPad( + String description, + WidgetTesterCallback test, { + bool skip = false, + TestVariant variant = const DefaultTestVariant(), +}) { + testWidgetsOnIos( + description, + (WidgetTester tester) async { + tester.view + // Simulate an iPad Pro 12 + ..physicalSize = const Size(2048, 2732) + ..devicePixelRatio = 2.0; + + addTearDown(() { + tester.view.reset(); + }); + + await test(tester); + }, + skip: skip, + variant: variant, + ); +} + +void testWidgetsOnAndroidTablet( + String description, + WidgetTesterCallback test, { + bool skip = false, + TestVariant variant = const DefaultTestVariant(), +}) { + testWidgetsOnAndroid( + description, + (WidgetTester tester) async { + tester.view + // Simulate a Pixel tablet. + ..physicalSize = const Size(1600, 2560) + ..devicePixelRatio = 2.0; + + addTearDown(() { + tester.view.reset(); + }); + + await test(tester); + }, + skip: skip, + variant: variant, + ); +} + +const _expandedPhoneKeyboardHeight = 300.0; + +const _expandedIPadKeyboardHeight = 300.0; +// iPad can show a "minimized" keyboard, which takes up a short area at +// the bottom of the screen, and within that short area is a small +// toolbar that shows spelling suggestions along with a button that +// opens a keyboard options menu. +const _minimizedIPadKeyboardHeight = 69.0; + +const _expandedAndroidTabletKeyboardHeight = 300.0; +// Android tablets can show a "minimized" keyboard, which takes up a +// short area at the bottom of the screen, and within that short area +// is a small toolbar that includes delete, emojis, audio recording, and +// a button to open a menu. +const _minimizedAndroidTabletKeyboardHeight = 62.0; + +const _keyboardPanelHeightForTabletWithMinimizedKeyboard = 250.0; + +const _aboveKeyboardToolbarKey = ValueKey('toolbar'); +const _keyboardPanelKey = ValueKey('keyboardPanel'); diff --git a/super_editor/test/super_editor/mobile/super_editor_ios_overlay_controls_test.dart b/super_editor/test/super_editor/mobile/super_editor_ios_overlay_controls_test.dart index 6ac7dfa52..34a749ba2 100644 --- a/super_editor/test/super_editor/mobile/super_editor_ios_overlay_controls_test.dart +++ b/super_editor/test/super_editor/mobile/super_editor_ios_overlay_controls_test.dart @@ -314,5 +314,6 @@ Future _pumpSingleParagraphApp(WidgetTester tester) async { .createDocument() // Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor... .withSingleParagraph() + .simulateSoftwareKeyboardInsets(true) .pump(); } diff --git a/super_editor/test/super_editor/supereditor_test_tools.dart b/super_editor/test/super_editor/supereditor_test_tools.dart index 2b1b108a1..586784136 100644 --- a/super_editor/test/super_editor/supereditor_test_tools.dart +++ b/super_editor/test/super_editor/supereditor_test_tools.dart @@ -102,7 +102,7 @@ class TestSuperEditorConfigurator { TestSuperEditorConfigurator._fromExistingConfiguration(this._widgetTester, this._config); TestSuperEditorConfigurator._(this._widgetTester, MutableDocument document) - : _config = SuperEditorTestConfiguration(document); + : _config = SuperEditorTestConfiguration(_widgetTester, document); final WidgetTester _widgetTester; final SuperEditorTestConfiguration _config; @@ -208,6 +208,20 @@ class TestSuperEditorConfigurator { return this; } + /// When `true`, adds [MediaQuery] view insets to simulate the appearance of a software keyboard + /// whenever the IME connection is active - when `false`, does nothing. + TestSuperEditorConfigurator simulateSoftwareKeyboardInsets( + bool doSimulation, { + double simulatedKeyboardHeight = 300, + bool animateKeyboard = false, + }) { + _config + ..simulateSoftwareKeyboardInsets = doSimulation + ..simulatedKeyboardHeight = simulatedKeyboardHeight + ..animateSimulatedSoftwareKeyboard = animateKeyboard; + return this; + } + /// Configures the [SuperEditor] with the given IME [policies], which dictate the interactions /// between focus, selection, and the platform IME, including software keyborads on mobile. TestSuperEditorConfigurator withImePolicies(SuperEditorImePolicies policies) { @@ -228,6 +242,13 @@ class TestSuperEditorConfigurator { return this; } + /// Configures the [SuperEditor] with the given [isImeConnected] notifier, which allows test + /// code to listen for changes to the IME connection from within [SuperEditor]. + TestSuperEditorConfigurator withImeConnectionNotifier(ValueNotifier? isImeConnected) { + _config.isImeConnected = isImeConnected ?? ValueNotifier(false); + return this; + } + TestSuperEditorConfigurator withAddedKeyboardActions({ List prepend = const [], List append = const [], @@ -454,7 +475,9 @@ class TestSuperEditorConfigurator { /// Builds a complete screen experience, which includes the given [superEditor]. Widget _buildWidgetTree(Widget superEditor) { if (_config.widgetTreeBuilder != null) { - return _config.widgetTreeBuilder!(superEditor); + return _buildSimulatedSoftwareKeyboard( + child: _config.widgetTreeBuilder!(superEditor), + ); } return MaterialApp( theme: _config.appTheme, @@ -465,24 +488,39 @@ class TestSuperEditorConfigurator { // Use our own version of the shortcuts, so we can set `debugIsWebOverride` to `true` to force // Flutter to pick the web shortcuts. shortcuts: defaultFlutterShortcuts, - home: Scaffold( - appBar: _config.appBarHeight != null - ? PreferredSize( - preferredSize: ui.Size(double.infinity, _config.appBarHeight!), - child: SafeArea( - child: SizedBox( - height: _config.appBarHeight!, - child: const ColoredBox(color: Colors.yellow), + home: _buildSimulatedSoftwareKeyboard( + child: Scaffold( + appBar: _config.appBarHeight != null + ? PreferredSize( + preferredSize: ui.Size(double.infinity, _config.appBarHeight!), + child: SafeArea( + child: SizedBox( + height: _config.appBarHeight!, + child: const ColoredBox(color: Colors.yellow), + ), ), - ), - ) - : null, - body: superEditor, + ) + : null, + body: superEditor, + resizeToAvoidBottomInset: false, + ), ), debugShowCheckedModeBanner: false, ); } + Widget _buildSimulatedSoftwareKeyboard({ + required Widget child, + }) { + return SoftwareKeyboardHeightSimulator( + tester: _config.tester, + isEnabled: _config.simulateSoftwareKeyboardInsets, + keyboardHeight: _config.simulatedKeyboardHeight, + animateKeyboard: _config.animateSimulatedSoftwareKeyboard, + child: child, + ); + } + /// Constrains the width and height of the given [superEditor], based on configurations /// in this class. Widget _buildConstrainedContent(Widget superEditor) { @@ -597,6 +635,7 @@ class _TestSuperEditorState extends State<_TestSuperEditor> { imePolicies: widget.testConfiguration.imePolicies ?? const SuperEditorImePolicies(), imeConfiguration: widget.testConfiguration.imeConfiguration, imeOverrides: widget.testConfiguration.imeOverrides, + isImeConnected: widget.testConfiguration.isImeConnected, keyboardActions: [ ...widget.testConfiguration.prependedKeyboardActions, ...(widget.testConfiguration.inputSource == TextInputSource.ime @@ -665,7 +704,9 @@ class _TestSuperEditorState extends State<_TestSuperEditor> { } class SuperEditorTestConfiguration { - SuperEditorTestConfiguration(this.document); + SuperEditorTestConfiguration(this.tester, this.document); + + final WidgetTester tester; ThemeData? appTheme; Key? key; @@ -701,9 +742,13 @@ class SuperEditorTestConfiguration { Color? androidCaretColor; SoftwareKeyboardController? softwareKeyboardController; + bool simulateSoftwareKeyboardInsets = false; + double simulatedKeyboardHeight = 300; + bool animateSimulatedSoftwareKeyboard = false; SuperEditorImePolicies? imePolicies; SuperEditorImeConfiguration? imeConfiguration; DeltaTextInputClientDecorator? imeOverrides; + ValueNotifier isImeConnected = ValueNotifier(false); Map? selectorHandlers; final prependedKeyboardActions = []; final appendedKeyboardActions = []; diff --git a/super_editor/test/test_tools_user_input.dart b/super_editor/test/test_tools_user_input.dart index c416d01b5..efbfaddbc 100644 --- a/super_editor/test/test_tools_user_input.dart +++ b/super_editor/test/test_tools_user_input.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/src/infrastructure/platforms/platform.dart'; import 'package:super_editor/super_editor.dart'; @@ -34,6 +35,170 @@ class InputAndGestureTuple { } } +/// A widget that simulates the software keyboard appearance and disappearance. +/// +/// This works by listening to platform messages that show/hide the software keyboard +/// and animating the `MediaQuery` bottom insets to reflect the height of the keyboard. +/// +/// Place this widget above the `Scaffold` in the widget tree. +class SoftwareKeyboardHeightSimulator extends StatefulWidget { + const SoftwareKeyboardHeightSimulator({ + required this.tester, + this.isEnabled = true, + this.enableForAllPlatforms = false, + this.keyboardHeight = 300, + this.animateKeyboard = false, + required this.child, + }); + + /// Flutter's [WidgetTester], which is used to intercept platform messages + /// about opening/closing the keyboard. + final WidgetTester tester; + + /// Whether or not to enable the simulated software keyboard insets. + /// + /// This property is provided so that clients don't need to conditionally add/remove + /// this widget from the tree. Instead this flag can be flipped, as needed. + final bool isEnabled; + + /// Whether to simulate software keyboard insets for all platforms (`true`), or whether to + /// only simulate software keyboard insets for mobile platforms, e.g., Android, iOS (`false`). + final bool enableForAllPlatforms; + + /// The vertical space, in logical pixels, to occupy at the bottom of the screen to simulate the appearance + /// of a keyboard. + final double keyboardHeight; + + /// Whether to simulate keyboard open/closing animations. + /// + /// These animations change the keyboard insets over time, similar to how a real + /// software keyboard slides up/down. However, this also means that clients need to + /// `pumpAndSettle()` to ensure the animation is complete. If you want to avoid `pumpAndSettle()` + /// and you don't care about the animation, then pass `false` to disable the animations. + final bool animateKeyboard; + + final Widget child; + + @override + State createState() => _SoftwareKeyboardHeightSimulatorState(); +} + +class _SoftwareKeyboardHeightSimulatorState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 600), + vsync: this, + ); + + if (widget.isEnabled) { + _setupPlatformMethodInterception(); + } + } + + @override + void didUpdateWidget(covariant SoftwareKeyboardHeightSimulator oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.tester != oldWidget.tester && widget.isEnabled) { + _setupPlatformMethodInterception(); + } + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + void _showKeyboard() { + if (_animationController.isForwardOrCompleted) { + // The keyboard is either fully visible or animating its entrance. + return; + } + + if (!widget.animateKeyboard) { + _animationController.value = 1.0; + return; + } + + _animationController.forward(); + } + + void _hideKeyboard() { + if (const [AnimationStatus.dismissed, AnimationStatus.reverse].contains(_animationController.status)) { + // The keyboard is either hidden or animating its exit. + return; + } + + if (!widget.animateKeyboard) { + _animationController.value = 0.0; + return; + } + + _animationController.reverse(); + } + + void _setupPlatformMethodInterception() { + widget.tester.interceptChannel(SystemChannels.textInput.name) // + ..interceptMethod( + 'TextInput.show', + (methodCall) { + _showKeyboard(); + return null; + }, + ) + ..interceptMethod( + 'TextInput.setClient', + (methodCall) { + _showKeyboard(); + return null; + }, + ) + ..interceptMethod( + 'TextInput.clearClient', + (methodCall) { + _hideKeyboard(); + return null; + }, + ) + ..interceptMethod( + 'TextInput.hide', + (methodCall) { + _hideKeyboard(); + return null; + }, + ); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animationController, + builder: (context, _) { + final realMediaQuery = MediaQuery.of(context); + final isRelevantPlatform = widget.enableForAllPlatforms || + (defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS); + final shouldSimulate = widget.isEnabled && isRelevantPlatform; + + return MediaQuery( + data: realMediaQuery.copyWith( + viewInsets: realMediaQuery.viewInsets.copyWith( + bottom: shouldSimulate + ? widget.keyboardHeight * _animationController.value + : realMediaQuery.viewInsets.bottom, + ), + ), + child: widget.child, + ); + }, + ); + } +} + /// A [TextInputConnection] that tracks the number of content updates, to verify /// within tests. class ImeConnectionWithUpdateCount extends TextInputConnectionDecorator {