From 26eaa5edc411ce903bb3aa68d6108f0830615bf2 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Sun, 4 Aug 2024 22:59:31 -0300 Subject: [PATCH 01/13] [SuperEditor][Android][iOS] - Add tooling to more easily create keyboard toolbar experiences (Resolves #2209) --- .../demo_panel_behind_keyboard.dart | 341 ++++++++------- .../keyboard_panel_scaffold.dart | 250 +++++++++++ super_editor/lib/super_editor.dart | 1 + .../keyboard_panel_scaffold_test.dart | 407 ++++++++++++++++++ 4 files changed, 846 insertions(+), 153 deletions(-) create mode 100644 super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart create mode 100644 super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart 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..28cc1159b 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'; @@ -23,30 +21,27 @@ class _PanelBehindKeyboardDemoState extends State { late MutableDocumentComposer _composer; late Editor _editor; 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(softwareKeyboardController: _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(); _composer.dispose(); _focusNode.dispose(); super.dispose(); @@ -124,7 +119,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 +139,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 +159,200 @@ 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: KeyboardPanelScaffold( + controller: _keyboardPanelController, + contentBuilder: _buildSuperEditor, + aboveKeyboardBuilder: _buildTopPanel, + keyboardPanelBuilder: _buildKeyboardPanel, ), ); } -} -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.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.toggleKeyboard(), + 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( - children: [ - const SizedBox(width: 24), - GestureDetector( - onTap: _closeKeyboardAndPanel, - child: const Icon(Icons.close), + Widget _buildKeyboardPanel(BuildContext context) { + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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'), ), - const SizedBox(width: 24), - ], + ), + 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'), + ), + ), + ], + ), + ), + ); + } + + 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, ), ), - SizedBox( - width: double.infinity, - height: _maxBottomInsets, - child: ColoredBox( - color: Colors.grey.shade300, + textStyle: WidgetStateProperty.all( + TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, ), ), - ], + ), + onPressed: onPressed, + child: child, ); } } - -enum _InputState { - open, - closed, -} 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..7b4eec76a --- /dev/null +++ b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart @@ -0,0 +1,250 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/src/default_editor/document_ime/document_input_ime.dart'; + +/// A widget that allows displaying an arbitrary widget occuping the space of the software keyboard. +/// +/// A typical use case is a chat application switching between the software keyboard +/// and an emoji panel. +/// +/// 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 [aboveKeyboardBuilder] 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 [contentBuilder] is positioned at the top, using all the available +/// height. +/// +/// Use the [controller] to show/hide the keyboard panel and software keyboard. +/// +/// It is required that [Scaffold.resizeToAvoidBottomInset] is set to `false`. +class KeyboardPanelScaffold extends StatefulWidget { + const KeyboardPanelScaffold({ + super.key, + required this.controller, + required this.contentBuilder, + required this.aboveKeyboardBuilder, + required this.keyboardPanelBuilder, + }); + + /// Controls the visibility of the keyboard panel and software keyboard. + final KeyboardPanelController controller; + + /// Builds the content that fills the available height. + final Widget Function(BuildContext context, bool isKeyboardPanelVisible) contentBuilder; + + /// Builds the panel that is shown above the keyboard panel. + final Widget Function(BuildContext context, bool isKeyboardPanelVisible) aboveKeyboardBuilder; + + /// Builds the keyboard panel. + final WidgetBuilder keyboardPanelBuilder; + + @override + State createState() => _KeyboardPanelScaffoldState(); +} + +class _KeyboardPanelScaffoldState extends State with SingleTickerProviderStateMixin { + /// 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]. + double _keyboardHeight = 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; + + /// 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; + + @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.addListener(_onKeyboardPanelChanged); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _updateKeyboardHeightForCurrentViewInsets(); + } + + @override + void didUpdateWidget(KeyboardPanelScaffold oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + oldWidget.controller.removeListener(_onKeyboardPanelChanged); + widget.controller.addListener(_onKeyboardPanelChanged); + } + } + + @override + void dispose() { + widget.controller.removeListener(_onKeyboardPanelChanged); + _panelExitAnimation.removeListener(_updatePanelForExitAnimation); + _panelExitAnimation.dispose(); + super.dispose(); + } + + void _onKeyboardPanelChanged() { + if (!widget.controller.wantsToShowKeyboardPanel && + !widget.controller.wantsToShowSoftwareKeyboard && + _latestViewInsets.bottom == 0.0) { + // 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); + return; + } + + setState(() { + _updateKeyboardHeightForCurrentViewInsets(); + }); + } + + /// Updates the keyboard height based on the view insets of the enclosing `MediaQuery`. + void _updateKeyboardHeightForCurrentViewInsets() { + final newInsets = MediaQuery.of(context).viewInsets; + final newBottomInset = newInsets.bottom; + final isKeyboardCollapsing = newBottomInset < _latestViewInsets.bottom; + + _latestViewInsets = newInsets; + + if (newBottomInset > _maxBottomInsets) { + // The keyboard is expanding. + _maxBottomInsets = newBottomInset; + _keyboardHeight = _maxBottomInsets; + return; + } + + if (isKeyboardCollapsing && !widget.controller.wantsToShowKeyboardPanel) { + // The keyboard is collapsing and we don't want the keyboard panel to be visible. + // Follow the keyboard back down. + _maxBottomInsets = newBottomInset; + _keyboardHeight = _maxBottomInsets; + return; + } + } + + /// Animates the panel height when the software keyboard is closed and the user wants + /// to close the keyboard panel. + void _updatePanelForExitAnimation() { + setState(() { + _keyboardHeight = _maxBottomInsets * Curves.easeInQuad.transform(_panelExitAnimation.value); + if (_panelExitAnimation.status == AnimationStatus.dismissed) { + // The panel has been fully collapsed. Reset the max known bottom insets. + _maxBottomInsets = 0.0; + } + }); + } + + @override + Widget build(BuildContext context) { + final wantsToShowKeyboardPanel = widget.controller.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. + (widget.controller.wantsToShowSoftwareKeyboard && _latestViewInsets.bottom < _keyboardHeight); + return Column( + children: [ + Expanded( + child: widget.contentBuilder( + context, + widget.controller.wantsToShowKeyboardPanel, + ), + ), + widget.aboveKeyboardBuilder( + context, + widget.controller.wantsToShowKeyboardPanel, + ), + SizedBox( + height: _keyboardHeight, + child: wantsToShowKeyboardPanel ? widget.keyboardPanelBuilder(context) : null, + ), + ], + ); + } +} + +/// Shows and hides the keyboard panel and software keyboard. +class KeyboardPanelController with ChangeNotifier { + KeyboardPanelController({ + required this.softwareKeyboardController, + }); + + final SoftwareKeyboardController softwareKeyboardController; + + bool get wantsToShowKeyboardPanel => _wantsToShowKeyboardPanel; + bool _wantsToShowKeyboardPanel = false; + + bool get wantsToShowSoftwareKeyboard => _wantsToShowSoftwareKeyboard; + bool _wantsToShowSoftwareKeyboard = false; + + void showKeyboardPanel() { + _wantsToShowKeyboardPanel = true; + _wantsToShowSoftwareKeyboard = false; + softwareKeyboardController.close(); + notifyListeners(); + } + + void showSoftwareKeyboard() { + _wantsToShowKeyboardPanel = false; + _wantsToShowSoftwareKeyboard = true; + softwareKeyboardController.open(); + notifyListeners(); + } + + /// Switch between the software keyboard and the keyboar panel. + void toggleKeyboard() { + if (_wantsToShowKeyboardPanel) { + showSoftwareKeyboard(); + } else { + showKeyboardPanel(); + } + } + + void closeKeyboardAndPanel() { + _wantsToShowKeyboardPanel = false; + _wantsToShowSoftwareKeyboard = false; + softwareKeyboardController.close(); + notifyListeners(); + } +} 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..212cedd83 --- /dev/null +++ b/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart @@ -0,0 +1,407 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.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', () { + testWidgetsOnMobile('does not show panel upon initialization', (tester) async { + await _pumpTestApp(tester); + + // Ensure the keyboard panel is not visible. + expect(find.byKey(_keyboardPanelKey), findsNothing); + }); + + testWidgetsOnMobile('shows above-keyboard panel at the bottom when there is no keyboard', (tester) async { + await _pumpTestApp(tester); + + // Ensure the above-keyboard panel sits at the bottom of the screen. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardPanelKey)).dy, + equals(tester.getSize(find.byType(MaterialApp)).height), + ); + }); + + testWidgetsOnMobile('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); + }); + + testWidgetsOnMobile('shows above-keyboard panel above the keyboard', (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 above-keyboard panel sits aboce the software keyboard. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardPanelKey)).dy, + equals(tester.getSize(find.byType(MaterialApp)).height - _keyboardHeight), + ); + }); + + testWidgetsOnMobile('shows above-keyboard panel above the keyboard when toggling panels and showing the keyboard', + (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController: 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(); + + // 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(_aboveKeyboardPanelKey)).dy, + equals(tester.getSize(find.byType(MaterialApp)).height - _keyboardHeight), + ); + }); + + testWidgetsOnMobile('shows keyboard panel upon request', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController: 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); + }); + + testWidgetsOnMobile('displays panel with the same height as the keyboard', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController: 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 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(_keyboardHeight), + ); + + // Ensure the above-keyboard panel sits immediately above the keyboard panel. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardPanelKey)).dy, + equals(tester.getSize(find.byType(MaterialApp)).height - _keyboardHeight), + ); + }); + + testWidgetsOnMobile('hides the panel when toggling the keyboard', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController: 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 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.toggleKeyboard(); + 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(_aboveKeyboardPanelKey)).dy, + equals(tester.getSize(find.byType(MaterialApp)).height - _keyboardHeight), + ); + }); + + testWidgetsOnMobile('hides the panel upon request', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController: 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 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(_aboveKeyboardPanelKey)).dy, + equals(tester.getSize(find.byType(MaterialApp)).height), + ); + }); + + testWidgetsOnMobile('shows above-keyboard panel at the bottom when closing the panel and the keyboard', + (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController: 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 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 panel sits at the bottom of the screen. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardPanelKey)).dy, + tester.getSize(find.byType(MaterialApp)).height, + ); + }); + }); +} + +/// 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 comunicates with the IME to show/hide +/// the software keyboard. +Future _pumpTestApp( + WidgetTester tester, { + KeyboardPanelController? controller, + SoftwareKeyboardController? softwareKeyboardController, +}) async { + final keyboardController = softwareKeyboardController ?? SoftwareKeyboardController(); + final keyboardPanelController = controller ?? KeyboardPanelController(softwareKeyboardController: keyboardController); + + await tester // + .createDocument() + .withLongDoc() + .withSoftwareKeyboardController(keyboardController) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: _SoftwareKeyboardHeightSimulator( + tester: tester, + keyboardHeight: _keyboardHeight, + child: Scaffold( + resizeToAvoidBottomInset: false, + body: KeyboardPanelScaffold( + controller: keyboardPanelController, + contentBuilder: (context, isKeyboardPanelVisible) => superEditor, + aboveKeyboardBuilder: (context, isKeyboardPanelVisible) => SizedBox( + key: _aboveKeyboardPanelKey, + height: 54, + ), + keyboardPanelBuilder: (context) => ColoredBox( + key: _keyboardPanelKey, + color: Colors.red, + ), + ), + ), + ), + ), + ) + .pump(); +} + +/// 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, + required this.keyboardHeight, + required this.child, + }); + + final WidgetTester tester; + + /// The desired height of the software keyboard. + final double keyboardHeight; + + final Widget child; + + @override + State<_SoftwareKeyboardHeightSimulator> createState() => _SoftwareKeyboardHeightSimulatorState(); +} + +class _SoftwareKeyboardHeightSimulatorState extends State<_SoftwareKeyboardHeightSimulator> + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 600), + vsync: this, + ); + + _setupPlatformMethodInterception(); + } + + @override + void didUpdateWidget(covariant _SoftwareKeyboardHeightSimulator oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.tester != oldWidget.tester) { + _setupPlatformMethodInterception(); + } + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + void _showKeyboard() { + if (_animationController.isForwardOrCompleted) { + // The keyboard is either fully visible or animating its entrance. + return; + } + + _animationController.forward(); + } + + void _hideKeyboard() { + if (const [AnimationStatus.dismissed, AnimationStatus.reverse].contains(_animationController.status)) { + // The keyboard is either hidden or animating its exit. + 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) { + final mediaQuery = MediaQuery.of(context); + + return AnimatedBuilder( + animation: _animationController, + builder: (context, _) { + return MediaQuery( + data: mediaQuery.copyWith( + viewInsets: mediaQuery.viewInsets.copyWith( + bottom: widget.keyboardHeight * _animationController.value, + ), + ), + child: widget.child, + ); + }, + ); + } +} + +const _keyboardHeight = 400.0; +const _aboveKeyboardPanelKey = ValueKey('aboveKeyboardPanel'); +const _keyboardPanelKey = ValueKey('keyboardPanel'); From eb81ed76c49416ab8e058cc9bf44cab845ad6197 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Tue, 6 Aug 2024 20:03:07 -0300 Subject: [PATCH 02/13] PR updates --- .../lib/src/infrastructure/keyboard_panel_scaffold.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart index 7b4eec76a..b1fec94ce 100644 --- a/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart +++ b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart @@ -13,12 +13,14 @@ import 'package:super_editor/src/default_editor/document_ime/document_input_ime. /// 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 [contentBuilder] is positioned at the top, using all the available -/// 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. /// -/// It is required that [Scaffold.resizeToAvoidBottomInset] is set to `false`. +/// It is required that the enclosing [Scaffold] has `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, From 082c0d7ecefee6b9d3074e5e3c58d781ba43a829 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Mon, 12 Aug 2024 20:31:57 -0300 Subject: [PATCH 03/13] Display panels in an OverlayPortal --- .../demo_panel_behind_keyboard.dart | 238 ++++++++++-------- .../keyboard_panel_scaffold.dart | 78 ++++-- .../keyboard_panel_scaffold_test.dart | 46 +++- 3 files changed, 237 insertions(+), 125 deletions(-) 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 28cc1159b..0d938c746 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 @@ -159,11 +159,18 @@ class _PanelBehindKeyboardDemoState extends State { Widget build(BuildContext context) { return Scaffold( resizeToAvoidBottomInset: false, - body: KeyboardPanelScaffold( - controller: _keyboardPanelController, - contentBuilder: _buildSuperEditor, - aboveKeyboardBuilder: _buildTopPanel, - keyboardPanelBuilder: _buildKeyboardPanel, + body: SafeArea( + bottom: false, + left: false, + right: false, + child: Column( + children: [ + _buildTopPanelToggle(context), + Expanded( + child: _buildSuperEditor(context, _isKeyboardPanelVisible), + ), + ], + ), ), ); } @@ -186,6 +193,20 @@ class _PanelBehindKeyboardDemoState extends State { ); } + Widget _buildTopPanelToggle(BuildContext context) { + return KeyboardPanelScaffold( + controller: _keyboardPanelController, + aboveKeyboardBuilder: _buildTopPanel, + keyboardPanelBuilder: _buildKeyboardPanel, + contentBuilder: (context, wantsToShowKeyboardPanel) { + return ElevatedButton( + onPressed: _keyboardPanelController.toggleAboveKeyboardPanel, + child: Text('Toggle above-keyboard panel'), + ); + }, + ); + } + Widget _buildTopPanel(BuildContext context, bool isKeyboardPanelVisible) { return Container( width: double.infinity, @@ -210,121 +231,124 @@ class _PanelBehindKeyboardDemoState extends State { } Widget _buildKeyboardPanel(BuildContext context) { - return SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Alignment', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - Row( - children: [ - Expanded( - child: _buildKeyboardPanelButton( - onPressed: () {}, - child: Icon(Icons.format_align_left), + return ColoredBox( + color: Theme.of(context).scaffoldBackgroundColor, + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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_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_right), + ), ), - ), - SizedBox(width: 8), - Expanded( - child: _buildKeyboardPanelButton( - onPressed: () {}, - child: Icon(Icons.format_align_justify), + 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(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_italic), + ), ), - ), - SizedBox(width: 8), - Expanded( - child: _buildKeyboardPanelButton( - onPressed: () {}, - child: Icon(Icons.format_strikethrough), + 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'), ), - ], - ), - SizedBox(height: 8), - Text( - 'Convertions', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - SizedBox( - width: double.infinity, - child: _buildKeyboardPanelButton( - onPressed: () {}, - child: Text('Header 1'), ), - ), - 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('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('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('Ordered List'), + ), ), - ), - SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: _buildKeyboardPanelButton( - onPressed: () {}, - child: Text('Unordered List'), + SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: _buildKeyboardPanelButton( + onPressed: () {}, + child: Text('Unordered List'), + ), ), - ), - ], + ], + ), ), ), ); diff --git a/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart index b1fec94ce..e9de1a6e7 100644 --- a/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart +++ b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart @@ -79,6 +79,9 @@ class _KeyboardPanelScaffoldState extends State with Sing /// 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(); + @override void initState() { super.initState(); @@ -127,6 +130,17 @@ class _KeyboardPanelScaffoldState extends State with Sing } void _onKeyboardPanelChanged() { + if (!widget.controller.wantsToShowAboveKeyboardPanel && !widget.controller.wantsToShowKeyboardPanel) { + _overlayPortalController.hide(); + return; + } + + if (!_overlayPortalController.isShowing) { + // The user wants to show the above-keyboard panel or the keyboard panel, but the overlay + // isn't visible. Show it. + _overlayPortalController.show(); + } + if (!widget.controller.wantsToShowKeyboardPanel && !widget.controller.wantsToShowSoftwareKeyboard && _latestViewInsets.bottom == 0.0) { @@ -185,23 +199,34 @@ class _KeyboardPanelScaffoldState extends State with Sing // 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. (widget.controller.wantsToShowSoftwareKeyboard && _latestViewInsets.bottom < _keyboardHeight); - return Column( - children: [ - Expanded( - child: widget.contentBuilder( - context, - widget.controller.wantsToShowKeyboardPanel, + + return OverlayPortal( + controller: _overlayPortalController, + overlayChildBuilder: (context) { + return Positioned( + bottom: 0, + left: 0, + right: 0, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.controller.wantsToShowAboveKeyboardPanel) + widget.aboveKeyboardBuilder( + context, + widget.controller.wantsToShowKeyboardPanel, + ), + SizedBox( + height: _keyboardHeight, + child: wantsToShowKeyboardPanel ? widget.keyboardPanelBuilder(context) : null, + ), + ], ), - ), - widget.aboveKeyboardBuilder( - context, - widget.controller.wantsToShowKeyboardPanel, - ), - SizedBox( - height: _keyboardHeight, - child: wantsToShowKeyboardPanel ? widget.keyboardPanelBuilder(context) : null, - ), - ], + ); + }, + child: widget.contentBuilder( + context, + widget.controller.wantsToShowKeyboardPanel, + ), ); } } @@ -220,6 +245,9 @@ class KeyboardPanelController with ChangeNotifier { bool get wantsToShowSoftwareKeyboard => _wantsToShowSoftwareKeyboard; bool _wantsToShowSoftwareKeyboard = false; + bool get wantsToShowAboveKeyboardPanel => _wantsToShowAboveKeyboardPanel; + bool _wantsToShowAboveKeyboardPanel = false; + void showKeyboardPanel() { _wantsToShowKeyboardPanel = true; _wantsToShowSoftwareKeyboard = false; @@ -249,4 +277,22 @@ class KeyboardPanelController with ChangeNotifier { softwareKeyboardController.close(); notifyListeners(); } + + void showAboveKeyboardPanel() { + _wantsToShowAboveKeyboardPanel = true; + notifyListeners(); + } + + void hideAboveKeyboardPanel() { + _wantsToShowAboveKeyboardPanel = false; + notifyListeners(); + } + + void toggleAboveKeyboardPanel() { + if (_wantsToShowAboveKeyboardPanel) { + hideAboveKeyboardPanel(); + } else { + showAboveKeyboardPanel(); + } + } } diff --git a/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart b/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart index 212cedd83..b6ce6bb58 100644 --- a/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart +++ b/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart @@ -17,7 +17,18 @@ void main() { }); testWidgetsOnMobile('shows above-keyboard panel at the bottom when there is no keyboard', (tester) async { - await _pumpTestApp(tester); + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController: softwareKeyboardController); + + await _pumpTestApp( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + ); + + // Request to show the above-keyboard panel. + controller.showAboveKeyboardPanel(); + await tester.pump(); // Ensure the above-keyboard panel sits at the bottom of the screen. expect( @@ -37,7 +48,18 @@ void main() { }); testWidgetsOnMobile('shows above-keyboard panel above the keyboard', (tester) async { - await _pumpTestApp(tester); + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController: softwareKeyboardController); + + await _pumpTestApp( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + ); + + // Request to show the above-keyboard panel. + controller.showAboveKeyboardPanel(); + await tester.pump(); // Place the caret at the beginning of the document to show the software keyboard. await tester.placeCaretInParagraph('1', 0); @@ -60,6 +82,10 @@ void main() { softwareKeyboardController: softwareKeyboardController, ); + // Request to show the above-keyboard panel. + controller.showAboveKeyboardPanel(); + await tester.pump(); + // Place the caret at the beginning of the document to show the software keyboard. await tester.placeCaretInParagraph('1', 0); @@ -112,6 +138,10 @@ void main() { softwareKeyboardController: softwareKeyboardController, ); + // Request to show the above-keyboard panel. + controller.showAboveKeyboardPanel(); + await tester.pump(); + // Place the caret at the beginning of the document to show the software keyboard. await tester.placeCaretInParagraph('1', 0); @@ -142,6 +172,10 @@ void main() { softwareKeyboardController: softwareKeyboardController, ); + // Request to show the above-keyboard panel. + controller.showAboveKeyboardPanel(); + await tester.pump(); + // Place the caret at the beginning of the document to show the software keyboard. await tester.placeCaretInParagraph('1', 0); @@ -176,6 +210,10 @@ void main() { softwareKeyboardController: softwareKeyboardController, ); + // Request to show the above-keyboard panel. + controller.showAboveKeyboardPanel(); + await tester.pump(); + // Place the caret at the beginning of the document to show the software keyboard. await tester.placeCaretInParagraph('1', 0); @@ -210,6 +248,10 @@ void main() { softwareKeyboardController: softwareKeyboardController, ); + // Request to show the above-keyboard panel. + controller.showAboveKeyboardPanel(); + await tester.pump(); + // Place the caret at the beginning of the document to show the software keyboard. await tester.placeCaretInParagraph('1', 0); From aeea9342edb8fb4c42618e2cc1c9ad593e3be57b Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Mon, 23 Sep 2024 21:02:36 -0300 Subject: [PATCH 04/13] Add option to automatically show/hide the above-keyboard panel --- .../keyboard_panel_scaffold.dart | 98 ++++++++++++------- 1 file changed, 60 insertions(+), 38 deletions(-) diff --git a/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart index e9de1a6e7..7973bc11d 100644 --- a/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart +++ b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:super_editor/src/default_editor/document_ime/document_input_ime.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; /// A widget that allows displaying an arbitrary widget occuping the space of the software keyboard. /// @@ -65,7 +66,7 @@ class _KeyboardPanelScaffoldState extends State with Sing /// is animated from the latest [_maxBottomInsets] to zero. /// /// - Otherwise, it is equal to [_maxBottomInsets]. - double _keyboardHeight = 0.0; + final ValueNotifier _keyboardHeight = ValueNotifier(0.0); /// The latest view insets obtained from the enclosing `MediaQuery`. /// @@ -82,6 +83,10 @@ class _KeyboardPanelScaffoldState extends State with Sing /// Shows/hides the [OverlayPortal] containing the keyboard panel and above-keyboard panel. final OverlayPortalController _overlayPortalController = OverlayPortalController(); + bool get _wantsToShowAboveKeyboardPanel => + widget.controller.toolbarVisibility == KeyboardToolbarVisibility.visible || + (widget.controller.toolbarVisibility == KeyboardToolbarVisibility.auto && _keyboardHeight.value > 0); + @override void initState() { super.initState(); @@ -103,6 +108,8 @@ class _KeyboardPanelScaffoldState extends State with Sing _panelExitAnimation.addListener(_updatePanelForExitAnimation); widget.controller.addListener(_onKeyboardPanelChanged); + + onNextFrame((ts) => _overlayPortalController.show()); } @override @@ -126,21 +133,11 @@ class _KeyboardPanelScaffoldState extends State with Sing widget.controller.removeListener(_onKeyboardPanelChanged); _panelExitAnimation.removeListener(_updatePanelForExitAnimation); _panelExitAnimation.dispose(); + _overlayPortalController.hide(); super.dispose(); } void _onKeyboardPanelChanged() { - if (!widget.controller.wantsToShowAboveKeyboardPanel && !widget.controller.wantsToShowKeyboardPanel) { - _overlayPortalController.hide(); - return; - } - - if (!_overlayPortalController.isShowing) { - // The user wants to show the above-keyboard panel or the keyboard panel, but the overlay - // isn't visible. Show it. - _overlayPortalController.show(); - } - if (!widget.controller.wantsToShowKeyboardPanel && !widget.controller.wantsToShowSoftwareKeyboard && _latestViewInsets.bottom == 0.0) { @@ -167,7 +164,8 @@ class _KeyboardPanelScaffoldState extends State with Sing if (newBottomInset > _maxBottomInsets) { // The keyboard is expanding. _maxBottomInsets = newBottomInset; - _keyboardHeight = _maxBottomInsets; + _keyboardHeight.value = _maxBottomInsets; + return; } @@ -175,7 +173,7 @@ class _KeyboardPanelScaffoldState extends State with Sing // The keyboard is collapsing and we don't want the keyboard panel to be visible. // Follow the keyboard back down. _maxBottomInsets = newBottomInset; - _keyboardHeight = _maxBottomInsets; + _keyboardHeight.value = _maxBottomInsets; return; } } @@ -184,7 +182,7 @@ class _KeyboardPanelScaffoldState extends State with Sing /// to close the keyboard panel. void _updatePanelForExitAnimation() { setState(() { - _keyboardHeight = _maxBottomInsets * Curves.easeInQuad.transform(_panelExitAnimation.value); + _keyboardHeight.value = _maxBottomInsets * Curves.easeInQuad.transform(_panelExitAnimation.value); if (_panelExitAnimation.status == AnimationStatus.dismissed) { // The panel has been fully collapsed. Reset the max known bottom insets. _maxBottomInsets = 0.0; @@ -198,29 +196,38 @@ class _KeyboardPanelScaffoldState extends State with Sing // 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. - (widget.controller.wantsToShowSoftwareKeyboard && _latestViewInsets.bottom < _keyboardHeight); + (widget.controller.wantsToShowSoftwareKeyboard && _latestViewInsets.bottom < _keyboardHeight.value); return OverlayPortal( controller: _overlayPortalController, overlayChildBuilder: (context) { - return Positioned( - bottom: 0, - left: 0, - right: 0, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (widget.controller.wantsToShowAboveKeyboardPanel) - widget.aboveKeyboardBuilder( - context, - widget.controller.wantsToShowKeyboardPanel, - ), - SizedBox( - height: _keyboardHeight, - child: wantsToShowKeyboardPanel ? widget.keyboardPanelBuilder(context) : null, + return ValueListenableBuilder( + valueListenable: _keyboardHeight, + builder: (context, currentHeight, child) { + if (!_wantsToShowAboveKeyboardPanel && !wantsToShowKeyboardPanel) { + return const SizedBox.shrink(); + } + + return Positioned( + bottom: 0, + left: 0, + right: 0, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_wantsToShowAboveKeyboardPanel) + widget.aboveKeyboardBuilder( + context, + widget.controller.wantsToShowKeyboardPanel, + ), + SizedBox( + height: _keyboardHeight.value, + child: wantsToShowKeyboardPanel ? widget.keyboardPanelBuilder(context) : null, + ), + ], ), - ], - ), + ); + }, ); }, child: widget.contentBuilder( @@ -245,8 +252,12 @@ class KeyboardPanelController with ChangeNotifier { bool get wantsToShowSoftwareKeyboard => _wantsToShowSoftwareKeyboard; bool _wantsToShowSoftwareKeyboard = false; - bool get wantsToShowAboveKeyboardPanel => _wantsToShowAboveKeyboardPanel; - bool _wantsToShowAboveKeyboardPanel = false; + KeyboardToolbarVisibility get toolbarVisibility => _toolbarVisibility; + KeyboardToolbarVisibility _toolbarVisibility = KeyboardToolbarVisibility.auto; + set toolbarVisibility(KeyboardToolbarVisibility value) { + _toolbarVisibility = value; + notifyListeners(); + } void showKeyboardPanel() { _wantsToShowKeyboardPanel = true; @@ -279,20 +290,31 @@ class KeyboardPanelController with ChangeNotifier { } void showAboveKeyboardPanel() { - _wantsToShowAboveKeyboardPanel = true; + _toolbarVisibility = KeyboardToolbarVisibility.visible; notifyListeners(); } void hideAboveKeyboardPanel() { - _wantsToShowAboveKeyboardPanel = false; + _toolbarVisibility = KeyboardToolbarVisibility.hidden; notifyListeners(); } void toggleAboveKeyboardPanel() { - if (_wantsToShowAboveKeyboardPanel) { + if (_toolbarVisibility == KeyboardToolbarVisibility.visible) { hideAboveKeyboardPanel(); } else { showAboveKeyboardPanel(); } } } + +enum KeyboardToolbarVisibility { + /// The toolbar should stay hidden. + hidden, + + /// The toolbar should be visible. + visible, + + /// The toolbar should be visible only when the software keyboard is open. + auto, +} From cc874f82035d4cec910854300990b092e4554fef Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Tue, 24 Sep 2024 21:24:28 -0300 Subject: [PATCH 05/13] Add widget to resize the tree according to the panel size --- .../demos/mobile_chat/demo_mobile_chat.dart | 109 ++++++++++++++---- .../example/lib/main_super_editor_chat.dart | 1 + .../keyboard_panel_scaffold.dart | 100 +++++++++++++++- 3 files changed, 186 insertions(+), 24 deletions(-) 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..e0143199f 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,7 +9,10 @@ class MobileChatDemo extends StatefulWidget { } class _MobileChatDemoState extends State { + final FocusNode _focusNode = FocusNode(); late final Editor _editor; + late final KeyboardPanelController _keyboardPanelController; + final SoftwareKeyboardController _softwareKeyboardController = SoftwareKeyboardController(); @override void initState() { @@ -18,22 +21,49 @@ class _MobileChatDemoState extends State { final document = MutableDocument.empty(); final composer = MutableDocumentComposer(); _editor = createDefaultDocumentEditor(document: document, composer: composer); + _keyboardPanelController = KeyboardPanelController( + softwareKeyboardController: _softwareKeyboardController, + ); + } + + @override + void dispose() { + _keyboardPanelController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _endEditing() { + _keyboardPanelController.closeKeyboardAndPanel(); + + _editor.execute([ + const ClearSelectionRequest(), + ]); + + // If we clear SuperEditor's selection, but leave SuperEditor focused, then + // SuperEditor will automatically place the caret at the end of the document. + // This is because SuperEditor always expects a place for text input when it + // has focus. To prevent this from happening, we explicitly remove focus + // from SuperEditor. + _focusNode.unfocus(); } @override Widget build(BuildContext context) { - return 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(), + ), + ], + ), ); } @@ -63,20 +93,59 @@ class _MobileChatDemoState extends State { ], ), padding: const EdgeInsets.only(top: 16, bottom: 24), - child: CustomScrollView( - shrinkWrap: true, - slivers: [ - SuperEditor( - editor: _editor, + child: KeyboardPanelScaffold( + controller: _keyboardPanelController, + aboveKeyboardBuilder: _buildKeyboardToolbar, + keyboardPanelBuilder: (context) => Container( + color: Colors.blue, + height: 100, + ), + 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, + ), + ), + ], + ); + }, ), ), ], ); } + + Widget _buildKeyboardToolbar(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.toggleKeyboard(), + child: Icon(isKeyboardPanelVisible ? Icons.keyboard : Icons.keyboard_hide), + ), + const SizedBox(width: 24), + ], + ), + ); + } } final _chatStylesheet = defaultStylesheet.copyWith( 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/infrastructure/keyboard_panel_scaffold.dart b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart index 7973bc11d..df05a29db 100644 --- a/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart +++ b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart @@ -22,6 +22,9 @@ import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; /// It is required that the enclosing [Scaffold] has `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. +/// +/// Place a [KeyboardScaffoldSafeArea] higher in the widget tree to adjust the padding so +/// that the content is above the keyboard panel and software keyboard. class KeyboardPanelScaffold extends StatefulWidget { const KeyboardPanelScaffold({ super.key, @@ -87,6 +90,8 @@ class _KeyboardPanelScaffoldState extends State with Sing widget.controller.toolbarVisibility == KeyboardToolbarVisibility.visible || (widget.controller.toolbarVisibility == KeyboardToolbarVisibility.auto && _keyboardHeight.value > 0); + final _toolbarKey = GlobalKey(); + @override void initState() { super.initState(); @@ -165,7 +170,7 @@ class _KeyboardPanelScaffoldState extends State with Sing // The keyboard is expanding. _maxBottomInsets = newBottomInset; _keyboardHeight.value = _maxBottomInsets; - + onNextFrame((ts) => _updateSafeArea()); return; } @@ -174,6 +179,7 @@ class _KeyboardPanelScaffoldState extends State with Sing // Follow the keyboard back down. _maxBottomInsets = newBottomInset; _keyboardHeight.value = _maxBottomInsets; + onNextFrame((ts) => _updateSafeArea()); return; } } @@ -183,6 +189,7 @@ class _KeyboardPanelScaffoldState extends State with Sing 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; @@ -190,6 +197,17 @@ class _KeyboardPanelScaffoldState extends State with Sing }); } + /// 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.bottomInsets = _keyboardHeight.value + (toolbarSize?.height ?? 0); + } + @override Widget build(BuildContext context) { final wantsToShowKeyboardPanel = widget.controller.wantsToShowKeyboardPanel || @@ -216,9 +234,12 @@ class _KeyboardPanelScaffoldState extends State with Sing mainAxisSize: MainAxisSize.min, children: [ if (_wantsToShowAboveKeyboardPanel) - widget.aboveKeyboardBuilder( - context, - widget.controller.wantsToShowKeyboardPanel, + KeyedSubtree( + key: _toolbarKey, + child: widget.aboveKeyboardBuilder( + context, + widget.controller.wantsToShowKeyboardPanel, + ), ), SizedBox( height: _keyboardHeight.value, @@ -318,3 +339,74 @@ enum KeyboardToolbarVisibility { /// The toolbar should be visible only when the software keyboard is open. auto, } + +/// Applies padding to the bottom of the child to avoid the software keyboard and +/// the above-keyboard toolbar. +/// +/// The padding is set by a [KeyboardPanelScaffold] widget in the subtree. +class KeyboardScaffoldSafeArea extends StatefulWidget { + static KeyboardSafeAreaData of(BuildContext context) { + return maybeOf(context)!; + } + + static KeyboardSafeAreaData? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType<_InheritedKeyboardScaffoldSafeArea>()?.keyboardSafeAreaData; + } + + const KeyboardScaffoldSafeArea({ + super.key, + required this.child, + }); + + final Widget child; + + @override + State createState() => _KeyboardScaffoldSafeAreaState(); +} + +class _KeyboardScaffoldSafeAreaState extends State { + final KeyboardSafeAreaData _keyboardSafeAreaData = KeyboardSafeAreaData(); + + @override + Widget build(BuildContext context) { + return _InheritedKeyboardScaffoldSafeArea( + keyboardSafeAreaData: _keyboardSafeAreaData, + child: ListenableBuilder( + listenable: _keyboardSafeAreaData, + builder: (context, _) { + return Padding( + padding: EdgeInsets.only(bottom: _keyboardSafeAreaData.bottomInsets), + child: widget.child, + ); + }, + ), + ); + } +} + +class _InheritedKeyboardScaffoldSafeArea extends InheritedWidget { + const _InheritedKeyboardScaffoldSafeArea({ + required this.keyboardSafeAreaData, + required super.child, + }); + + final KeyboardSafeAreaData keyboardSafeAreaData; + + @override + bool updateShouldNotify(covariant _InheritedKeyboardScaffoldSafeArea oldWidget) { + return oldWidget.keyboardSafeAreaData != keyboardSafeAreaData; + } +} + +class KeyboardSafeAreaData with ChangeNotifier { + KeyboardSafeAreaData({ + double bottomInsets = 0.0, + }) : _bottomInsets = bottomInsets; + + double get bottomInsets => _bottomInsets; + double _bottomInsets; + set bottomInsets(double value) { + _bottomInsets = value; + notifyListeners(); + } +} From f083ab349510676efdb7ac43e5552e3793ffc392 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Sat, 28 Sep 2024 09:45:56 -0700 Subject: [PATCH 06/13] Change keyboard scaffold controller to hide keyboard instead of close IME connection --- .../src/default_editor/document_ime/ime_keyboard_control.dart | 4 ++++ .../lib/src/infrastructure/keyboard_panel_scaffold.dart | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) 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..b53dd5eb3 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 @@ -125,6 +125,10 @@ class SoftwareKeyboardController { _delegate?.open(); } + void hide() { + SystemChannels.textInput.invokeListMethod("TextInput.hide"); + } + /// Closes the software keyboard. void close() { assert(hasDelegate); diff --git a/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart index df05a29db..2ee1f7042 100644 --- a/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart +++ b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart @@ -283,7 +283,7 @@ class KeyboardPanelController with ChangeNotifier { void showKeyboardPanel() { _wantsToShowKeyboardPanel = true; _wantsToShowSoftwareKeyboard = false; - softwareKeyboardController.close(); + softwareKeyboardController.hide(); notifyListeners(); } @@ -306,7 +306,7 @@ class KeyboardPanelController with ChangeNotifier { void closeKeyboardAndPanel() { _wantsToShowKeyboardPanel = false; _wantsToShowSoftwareKeyboard = false; - softwareKeyboardController.close(); + softwareKeyboardController.hide(); notifyListeners(); } From 41e80b56ef13997b25012f9318a97a40f38e389f Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 1 Oct 2024 21:45:35 -0700 Subject: [PATCH 07/13] Reworking keyboard scaffold API --- .../demo_panel_behind_keyboard.dart | 8 +- .../demos/mobile_chat/demo_mobile_chat.dart | 8 +- .../document_ime/ime_keyboard_control.dart | 13 +- .../keyboard_panel_scaffold.dart | 413 +++++++++++++----- .../keyboard_panel_scaffold_test.dart | 38 +- 5 files changed, 343 insertions(+), 137 deletions(-) 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 0d938c746..0c8ca0f7c 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 @@ -30,7 +30,7 @@ class _PanelBehindKeyboardDemoState extends State { void initState() { super.initState(); - _keyboardPanelController = KeyboardPanelController(softwareKeyboardController: _keyboardController); + _keyboardPanelController = KeyboardPanelController(_keyboardController); _focusNode = FocusNode(); @@ -196,11 +196,11 @@ class _PanelBehindKeyboardDemoState extends State { Widget _buildTopPanelToggle(BuildContext context) { return KeyboardPanelScaffold( controller: _keyboardPanelController, - aboveKeyboardBuilder: _buildTopPanel, + toolbarBuilder: _buildTopPanel, keyboardPanelBuilder: _buildKeyboardPanel, contentBuilder: (context, wantsToShowKeyboardPanel) { return ElevatedButton( - onPressed: _keyboardPanelController.toggleAboveKeyboardPanel, + onPressed: _keyboardPanelController.toggleToolbar, child: Text('Toggle above-keyboard panel'), ); }, @@ -221,7 +221,7 @@ class _PanelBehindKeyboardDemoState extends State { ), const Spacer(), GestureDetector( - onTap: () => _keyboardPanelController.toggleKeyboard(), + onTap: () => _keyboardPanelController.toggleSoftwareKeyboardWithPanel(), child: Icon(isKeyboardPanelVisible ? Icons.keyboard : Icons.keyboard_hide), ), const SizedBox(width: 24), 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 e0143199f..6d4b78485 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 @@ -21,9 +21,7 @@ class _MobileChatDemoState extends State { final document = MutableDocument.empty(); final composer = MutableDocumentComposer(); _editor = createDefaultDocumentEditor(document: document, composer: composer); - _keyboardPanelController = KeyboardPanelController( - softwareKeyboardController: _softwareKeyboardController, - ); + _keyboardPanelController = KeyboardPanelController(_softwareKeyboardController); } @override @@ -95,7 +93,7 @@ class _MobileChatDemoState extends State { padding: const EdgeInsets.only(top: 16, bottom: 24), child: KeyboardPanelScaffold( controller: _keyboardPanelController, - aboveKeyboardBuilder: _buildKeyboardToolbar, + toolbarBuilder: _buildKeyboardToolbar, keyboardPanelBuilder: (context) => Container( color: Colors.blue, height: 100, @@ -138,7 +136,7 @@ class _MobileChatDemoState extends State { ), const Spacer(), GestureDetector( - onTap: () => _keyboardPanelController.toggleKeyboard(), + onTap: () => _keyboardPanelController.toggleSoftwareKeyboardWithPanel(), child: Icon(isKeyboardPanelVisible ? Icons.keyboard : Icons.keyboard_hide), ), const SizedBox(width: 24), 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 b53dd5eb3..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."); @@ -126,7 +131,8 @@ class SoftwareKeyboardController { } void hide() { - SystemChannels.textInput.invokeListMethod("TextInput.hide"); + assert(hasDelegate); + _delegate?.hide(); } /// Closes the software keyboard. @@ -145,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/infrastructure/keyboard_panel_scaffold.dart b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart index 2ee1f7042..199a49ff9 100644 --- a/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart +++ b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart @@ -1,56 +1,67 @@ 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'; -/// A widget that allows displaying an arbitrary widget occuping the space of the software keyboard. +/// 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 is a chat application switching between the software keyboard -/// and an emoji panel. +/// A typical use case for the keyboard panel is a chat application switching between the +/// software keyboard and an emoji panel. /// -/// 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. +/// 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 [aboveKeyboardBuilder] is positioned above the keyboard panel, when +/// 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. /// -/// It is required that the enclosing [Scaffold] has `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. -/// -/// Place a [KeyboardScaffoldSafeArea] higher in the widget tree to adjust the padding so -/// that the content is above 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.contentBuilder, - required this.aboveKeyboardBuilder, + required this.toolbarBuilder, required this.keyboardPanelBuilder, + required this.contentBuilder, }); - /// Controls the visibility of the keyboard panel and software keyboard. + /// Controls the visibility of the keyboard toolbar, keyboard panel, and software keyboard. final KeyboardPanelController controller; - /// Builds the content that fills the available height. - final Widget Function(BuildContext context, bool isKeyboardPanelVisible) contentBuilder; - - /// Builds the panel that is shown above the keyboard panel. - final Widget Function(BuildContext context, bool isKeyboardPanelVisible) aboveKeyboardBuilder; + /// 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. + /// Builds the keyboard panel that's displayed in place of the software keyboard. final WidgetBuilder keyboardPanelBuilder; + /// 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 { +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. @@ -92,6 +103,8 @@ class _KeyboardPanelScaffoldState extends State with Sing final _toolbarKey = GlobalKey(); + SoftwareKeyboardController? _softwareKeyboardController; + @override void initState() { super.initState(); @@ -112,7 +125,7 @@ class _KeyboardPanelScaffoldState extends State with Sing ); _panelExitAnimation.addListener(_updatePanelForExitAnimation); - widget.controller.addListener(_onKeyboardPanelChanged); + widget.controller.attach(this); onNextFrame((ts) => _overlayPortalController.show()); } @@ -128,37 +141,157 @@ class _KeyboardPanelScaffoldState extends State with Sing void didUpdateWidget(KeyboardPanelScaffold oldWidget) { super.didUpdateWidget(oldWidget); if (widget.controller != oldWidget.controller) { - oldWidget.controller.removeListener(_onKeyboardPanelChanged); - widget.controller.addListener(_onKeyboardPanelChanged); + oldWidget.controller.detach(); + widget.controller.attach(this); } } @override void dispose() { - widget.controller.removeListener(_onKeyboardPanelChanged); + widget.controller.detach(); + _panelExitAnimation.removeListener(_updatePanelForExitAnimation); _panelExitAnimation.dispose(); + _overlayPortalController.hide(); + super.dispose(); } - void _onKeyboardPanelChanged() { - if (!widget.controller.wantsToShowKeyboardPanel && - !widget.controller.wantsToShowSoftwareKeyboard && - _latestViewInsets.bottom == 0.0) { - // 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); + @override + void onAttached(SoftwareKeyboardController softwareKeyboardController) { + _softwareKeyboardController = softwareKeyboardController; + } + + @override + void onDetached() { + _softwareKeyboardController = null; + } + + /// 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; } - setState(() { - _updateKeyboardHeightForCurrentViewInsets(); - }); + _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(); + } } - /// Updates the keyboard height based on the view insets of the enclosing `MediaQuery`. + /// Shows the toolbar that's mounted to the top of the keyboard area. + @override + void showToolbar() { + _toolbarVisibility = KeyboardToolbarVisibility.visible; + _isToolbarVisible = true; + } + + /// Hides the toolbar that's mounted to the top of the keyboard area. + @override + void hideToolbar() { + _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() { + _wantsToShowKeyboardPanel = false; + _wantsToShowSoftwareKeyboard = true; + _softwareKeyboardController!.open(); + } + + /// Hides (doesn't close) the software keyboard, if it's open. + @override + void hideSoftwareKeyboard() { + _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() { + _wantsToShowKeyboardPanel = true; + _wantsToShowSoftwareKeyboard = false; + _softwareKeyboardController!.hide(); + } + + /// Hides the keyboard panel, if it's open. + @override + void hideKeyboardPanel() { + _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() { + _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; @@ -174,7 +307,7 @@ class _KeyboardPanelScaffoldState extends State with Sing return; } - if (isKeyboardCollapsing && !widget.controller.wantsToShowKeyboardPanel) { + 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; @@ -210,11 +343,11 @@ class _KeyboardPanelScaffoldState extends State with Sing @override Widget build(BuildContext context) { - final wantsToShowKeyboardPanel = widget.controller.wantsToShowKeyboardPanel || + 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. - (widget.controller.wantsToShowSoftwareKeyboard && _latestViewInsets.bottom < _keyboardHeight.value); + (_wantsToShowSoftwareKeyboard && _latestViewInsets.bottom < _keyboardHeight.value); return OverlayPortal( controller: _overlayPortalController, @@ -236,9 +369,9 @@ class _KeyboardPanelScaffoldState extends State with Sing if (_wantsToShowAboveKeyboardPanel) KeyedSubtree( key: _toolbarKey, - child: widget.aboveKeyboardBuilder( + child: widget.toolbarBuilder( context, - widget.controller.wantsToShowKeyboardPanel, + _wantsToShowKeyboardPanel, ), ), SizedBox( @@ -253,103 +386,167 @@ class _KeyboardPanelScaffoldState extends State with Sing }, child: widget.contentBuilder( context, - widget.controller.wantsToShowKeyboardPanel, + _wantsToShowKeyboardPanel, ), ); } } /// Shows and hides the keyboard panel and software keyboard. -class KeyboardPanelController with ChangeNotifier { - KeyboardPanelController({ - required this.softwareKeyboardController, - }); +class KeyboardPanelController { + KeyboardPanelController( + this._softwareKeyboardController, + ); - final SoftwareKeyboardController softwareKeyboardController; + void dispose() { + detach(); + } - bool get wantsToShowKeyboardPanel => _wantsToShowKeyboardPanel; - bool _wantsToShowKeyboardPanel = false; + final SoftwareKeyboardController _softwareKeyboardController; - bool get wantsToShowSoftwareKeyboard => _wantsToShowSoftwareKeyboard; - bool _wantsToShowSoftwareKeyboard = false; + KeyboardPanelScaffoldDelegate? _delegate; - KeyboardToolbarVisibility get toolbarVisibility => _toolbarVisibility; - KeyboardToolbarVisibility _toolbarVisibility = KeyboardToolbarVisibility.auto; - set toolbarVisibility(KeyboardToolbarVisibility value) { - _toolbarVisibility = value; - notifyListeners(); - } + /// 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; - void showKeyboardPanel() { - _wantsToShowKeyboardPanel = true; - _wantsToShowSoftwareKeyboard = false; - softwareKeyboardController.hide(); - notifyListeners(); + /// 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); } - void showSoftwareKeyboard() { - _wantsToShowKeyboardPanel = false; - _wantsToShowSoftwareKeyboard = true; - softwareKeyboardController.open(); - notifyListeners(); + /// 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; } - /// Switch between the software keyboard and the keyboar panel. - void toggleKeyboard() { - if (_wantsToShowKeyboardPanel) { - showSoftwareKeyboard(); - } else { - showKeyboardPanel(); - } - } + /// 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; - void closeKeyboardAndPanel() { - _wantsToShowKeyboardPanel = false; - _wantsToShowSoftwareKeyboard = false; - softwareKeyboardController.hide(); - notifyListeners(); - } + /// Shows the toolbar, if it's hidden, or hides the toolbar, if it's visible. + void toggleToolbar() => _delegate?.toggleToolbar(); - void showAboveKeyboardPanel() { - _toolbarVisibility = KeyboardToolbarVisibility.visible; - notifyListeners(); - } + /// Shows the toolbar that's mounted to the top of the keyboard area. + void showToolbar() => _delegate?.showToolbar(); - void hideAboveKeyboardPanel() { - _toolbarVisibility = KeyboardToolbarVisibility.hidden; - notifyListeners(); - } + /// Hides the toolbar that's mounted to the top of the keyboard area. + void hideToolbar() => _delegate?.hideToolbar(); - void toggleAboveKeyboardPanel() { - if (_toolbarVisibility == KeyboardToolbarVisibility.visible) { - hideAboveKeyboardPanel(); - } else { - showAboveKeyboardPanel(); - } - } + /// 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 stay hidden. + /// The toolbar should be hidden. hidden, /// The toolbar should be visible. visible, - /// The toolbar should be visible only when the software keyboard is open. + /// 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. /// -/// The padding is set by a [KeyboardPanelScaffold] widget in the subtree. +/// [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 KeyboardSafeAreaData of(BuildContext context) { + static KeyboardSafeAreaGeometry of(BuildContext context) { return maybeOf(context)!; } - static KeyboardSafeAreaData? maybeOf(BuildContext context) { + static KeyboardSafeAreaGeometry? maybeOf(BuildContext context) { return context.dependOnInheritedWidgetOfExactType<_InheritedKeyboardScaffoldSafeArea>()?.keyboardSafeAreaData; } @@ -365,7 +562,7 @@ class KeyboardScaffoldSafeArea extends StatefulWidget { } class _KeyboardScaffoldSafeAreaState extends State { - final KeyboardSafeAreaData _keyboardSafeAreaData = KeyboardSafeAreaData(); + final KeyboardSafeAreaGeometry _keyboardSafeAreaData = KeyboardSafeAreaGeometry(); @override Widget build(BuildContext context) { @@ -390,7 +587,7 @@ class _InheritedKeyboardScaffoldSafeArea extends InheritedWidget { required super.child, }); - final KeyboardSafeAreaData keyboardSafeAreaData; + final KeyboardSafeAreaGeometry keyboardSafeAreaData; @override bool updateShouldNotify(covariant _InheritedKeyboardScaffoldSafeArea oldWidget) { @@ -398,8 +595,10 @@ class _InheritedKeyboardScaffoldSafeArea extends InheritedWidget { } } -class KeyboardSafeAreaData with ChangeNotifier { - KeyboardSafeAreaData({ +/// Insets applied by a [KeyboardPanelScaffold] to an ancestor [KeyboardScaffoldSafeArea] +/// to deal with the presence or absence of the software keyboard. +class KeyboardSafeAreaGeometry with ChangeNotifier { + KeyboardSafeAreaGeometry({ double bottomInsets = 0.0, }) : _bottomInsets = bottomInsets; diff --git a/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart b/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart index b6ce6bb58..796d53942 100644 --- a/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart +++ b/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart @@ -18,7 +18,7 @@ void main() { testWidgetsOnMobile('shows above-keyboard panel at the bottom when there is no keyboard', (tester) async { final softwareKeyboardController = SoftwareKeyboardController(); - final controller = KeyboardPanelController(softwareKeyboardController: softwareKeyboardController); + final controller = KeyboardPanelController(softwareKeyboardController); await _pumpTestApp( tester, @@ -27,7 +27,7 @@ void main() { ); // Request to show the above-keyboard panel. - controller.showAboveKeyboardPanel(); + controller.showToolbar(); await tester.pump(); // Ensure the above-keyboard panel sits at the bottom of the screen. @@ -49,7 +49,7 @@ void main() { testWidgetsOnMobile('shows above-keyboard panel above the keyboard', (tester) async { final softwareKeyboardController = SoftwareKeyboardController(); - final controller = KeyboardPanelController(softwareKeyboardController: softwareKeyboardController); + final controller = KeyboardPanelController(softwareKeyboardController); await _pumpTestApp( tester, @@ -58,7 +58,7 @@ void main() { ); // Request to show the above-keyboard panel. - controller.showAboveKeyboardPanel(); + controller.showToolbar(); await tester.pump(); // Place the caret at the beginning of the document to show the software keyboard. @@ -74,7 +74,7 @@ void main() { testWidgetsOnMobile('shows above-keyboard panel above the keyboard when toggling panels and showing the keyboard', (tester) async { final softwareKeyboardController = SoftwareKeyboardController(); - final controller = KeyboardPanelController(softwareKeyboardController: softwareKeyboardController); + final controller = KeyboardPanelController(softwareKeyboardController); await _pumpTestApp( tester, @@ -83,7 +83,7 @@ void main() { ); // Request to show the above-keyboard panel. - controller.showAboveKeyboardPanel(); + controller.showToolbar(); await tester.pump(); // Place the caret at the beginning of the document to show the software keyboard. @@ -109,7 +109,7 @@ void main() { testWidgetsOnMobile('shows keyboard panel upon request', (tester) async { final softwareKeyboardController = SoftwareKeyboardController(); - final controller = KeyboardPanelController(softwareKeyboardController: softwareKeyboardController); + final controller = KeyboardPanelController(softwareKeyboardController); await _pumpTestApp( tester, @@ -130,7 +130,7 @@ void main() { testWidgetsOnMobile('displays panel with the same height as the keyboard', (tester) async { final softwareKeyboardController = SoftwareKeyboardController(); - final controller = KeyboardPanelController(softwareKeyboardController: softwareKeyboardController); + final controller = KeyboardPanelController(softwareKeyboardController); await _pumpTestApp( tester, @@ -139,7 +139,7 @@ void main() { ); // Request to show the above-keyboard panel. - controller.showAboveKeyboardPanel(); + controller.showToolbar(); await tester.pump(); // Place the caret at the beginning of the document to show the software keyboard. @@ -164,7 +164,7 @@ void main() { testWidgetsOnMobile('hides the panel when toggling the keyboard', (tester) async { final softwareKeyboardController = SoftwareKeyboardController(); - final controller = KeyboardPanelController(softwareKeyboardController: softwareKeyboardController); + final controller = KeyboardPanelController(softwareKeyboardController); await _pumpTestApp( tester, @@ -173,7 +173,7 @@ void main() { ); // Request to show the above-keyboard panel. - controller.showAboveKeyboardPanel(); + controller.showToolbar(); await tester.pump(); // Place the caret at the beginning of the document to show the software keyboard. @@ -187,7 +187,7 @@ void main() { expect(find.byKey(_keyboardPanelKey), findsOneWidget); // Hide the keyboard panel and show the software keyboard. - controller.toggleKeyboard(); + controller.toggleSoftwareKeyboardWithPanel(); await tester.pumpAndSettle(); // Ensure the keyboard panel is not visible. @@ -202,7 +202,7 @@ void main() { testWidgetsOnMobile('hides the panel upon request', (tester) async { final softwareKeyboardController = SoftwareKeyboardController(); - final controller = KeyboardPanelController(softwareKeyboardController: softwareKeyboardController); + final controller = KeyboardPanelController(softwareKeyboardController); await _pumpTestApp( tester, @@ -211,7 +211,7 @@ void main() { ); // Request to show the above-keyboard panel. - controller.showAboveKeyboardPanel(); + controller.showToolbar(); await tester.pump(); // Place the caret at the beginning of the document to show the software keyboard. @@ -240,7 +240,7 @@ void main() { testWidgetsOnMobile('shows above-keyboard panel at the bottom when closing the panel and the keyboard', (tester) async { final softwareKeyboardController = SoftwareKeyboardController(); - final controller = KeyboardPanelController(softwareKeyboardController: softwareKeyboardController); + final controller = KeyboardPanelController(softwareKeyboardController); await _pumpTestApp( tester, @@ -249,7 +249,7 @@ void main() { ); // Request to show the above-keyboard panel. - controller.showAboveKeyboardPanel(); + controller.showToolbar(); await tester.pump(); // Place the caret at the beginning of the document to show the software keyboard. @@ -289,7 +289,7 @@ Future _pumpTestApp( SoftwareKeyboardController? softwareKeyboardController, }) async { final keyboardController = softwareKeyboardController ?? SoftwareKeyboardController(); - final keyboardPanelController = controller ?? KeyboardPanelController(softwareKeyboardController: keyboardController); + final keyboardPanelController = controller ?? KeyboardPanelController(keyboardController); await tester // .createDocument() @@ -305,11 +305,11 @@ Future _pumpTestApp( body: KeyboardPanelScaffold( controller: keyboardPanelController, contentBuilder: (context, isKeyboardPanelVisible) => superEditor, - aboveKeyboardBuilder: (context, isKeyboardPanelVisible) => SizedBox( + toolbarBuilder: (context, isKeyboardPanelVisible) => const SizedBox( key: _aboveKeyboardPanelKey, height: 54, ), - keyboardPanelBuilder: (context) => ColoredBox( + keyboardPanelBuilder: (context) => const ColoredBox( key: _keyboardPanelKey, color: Colors.red, ), From 7827ec99dbe29c332369a280a7b01e81156673b0 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Fri, 11 Oct 2024 22:14:31 -0700 Subject: [PATCH 08/13] Automatically close the panel when the keyboard comes up on its own. --- .../demos/mobile_chat/demo_mobile_chat.dart | 104 ++++++++++++++---- .../keyboard_panel_scaffold.dart | 13 +++ 2 files changed, 95 insertions(+), 22 deletions(-) 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 6d4b78485..4aa60f493 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 @@ -11,9 +11,12 @@ class MobileChatDemo extends StatefulWidget { class _MobileChatDemoState extends State { final FocusNode _focusNode = FocusNode(); late final Editor _editor; + late final KeyboardPanelController _keyboardPanelController; final SoftwareKeyboardController _softwareKeyboardController = SoftwareKeyboardController(); + _Panel? _visiblePanel; + @override void initState() { super.initState(); @@ -21,6 +24,7 @@ class _MobileChatDemoState extends State { final document = MutableDocument.empty(); final composer = MutableDocumentComposer(); _editor = createDefaultDocumentEditor(document: document, composer: composer); + _keyboardPanelController = KeyboardPanelController(_softwareKeyboardController); } @@ -31,19 +35,16 @@ class _MobileChatDemoState extends State { super.dispose(); } - void _endEditing() { - _keyboardPanelController.closeKeyboardAndPanel(); - - _editor.execute([ - const ClearSelectionRequest(), - ]); - - // If we clear SuperEditor's selection, but leave SuperEditor focused, then - // SuperEditor will automatically place the caret at the end of the document. - // This is because SuperEditor always expects a place for text input when it - // has focus. To prevent this from happening, we explicitly remove focus - // from SuperEditor. - _focusNode.unfocus(); + void _togglePanel(_Panel panel) { + setState(() { + if (_visiblePanel == panel) { + _visiblePanel = null; + _keyboardPanelController.showSoftwareKeyboard(); + } else { + _visiblePanel = panel; + _keyboardPanelController.showKeyboardPanel(); + } + }); } @override @@ -94,10 +95,22 @@ class _MobileChatDemoState extends State { child: KeyboardPanelScaffold( controller: _keyboardPanelController, toolbarBuilder: _buildKeyboardToolbar, - keyboardPanelBuilder: (context) => Container( - color: Colors.blue, - height: 100, - ), + 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, @@ -123,21 +136,34 @@ class _MobileChatDemoState extends State { } 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), - GestureDetector( - onTap: _endEditing, - child: const Icon(Icons.close), + 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 Spacer(), GestureDetector( - onTap: () => _keyboardPanelController.toggleSoftwareKeyboardWithPanel(), - child: Icon(isKeyboardPanelVisible ? Icons.keyboard : Icons.keyboard_hide), + onTap: _keyboardPanelController.closeKeyboardAndPanel, + child: Icon(Icons.keyboard_hide), ), const SizedBox(width: 24), ], @@ -146,6 +172,40 @@ class _MobileChatDemoState extends State { } } +enum _Panel { + panel1, + panel2; +} + +class _PanelButton extends StatelessWidget { + const _PanelButton({ + required this.icon, + required this.isActive, + 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: [ StyleRule( diff --git a/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart index 199a49ff9..ac8562f92 100644 --- a/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart +++ b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart @@ -87,6 +87,9 @@ class _KeyboardPanelScaffoldState extends State /// 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 @@ -297,6 +300,16 @@ class _KeyboardPanelScaffoldState extends State final newBottomInset = newInsets.bottom; final isKeyboardCollapsing = newBottomInset < _latestViewInsets.bottom; + final isClosing = newBottomInset < _latestViewInsets.bottom; + if (_isKeyboardOpen && isClosing) { + // The keyboard went from open to closed. Update our cached state. + _isKeyboardOpen = false; + } else if (!_isKeyboardOpen && !isClosing) { + // 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) { From f5c36acae156c857ad4b5cc85fc64467336f1eef Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Sun, 13 Oct 2024 15:34:39 -0700 Subject: [PATCH 09/13] Don't show mobile toolbar when tapping to open the keyboard over a panel --- .../document_gestures_touch_android.dart | 17 ++++++++++++++++- .../document_gestures_touch_ios.dart | 16 +++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) 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. From 2ac9f6e8c158f467d58ba94589b62889a41dff65 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Mon, 14 Oct 2024 11:59:10 -0700 Subject: [PATCH 10/13] Automatically close an open panel when the IME disconnects --- .../demo_panel_behind_keyboard.dart | 7 +- .../demos/mobile_chat/demo_mobile_chat.dart | 57 +++++- .../supereditor_ime_interactor.dart | 11 ++ .../lib/src/default_editor/super_editor.dart | 8 + .../keyboard_panel_scaffold.dart | 34 +++- .../keyboard_panel_scaffold_test.dart | 131 +------------- ...uper_editor_ios_overlay_controls_test.dart | 1 + .../super_editor/supereditor_test_tools.dart | 29 ++- super_editor/test/test_tools_user_input.dart | 165 ++++++++++++++++++ 9 files changed, 312 insertions(+), 131 deletions(-) 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 0c8ca0f7c..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 @@ -20,10 +20,11 @@ class _PanelBehindKeyboardDemoState extends State { late MutableDocument _doc; late MutableDocumentComposer _composer; late Editor _editor; - final _keyboardController = SoftwareKeyboardController(); - late final KeyboardPanelController _keyboardPanelController; + final _isImeConnected = ValueNotifier(false); + final _keyboardController = SoftwareKeyboardController(); + late final KeyboardPanelController _keyboardPanelController; bool _isKeyboardPanelVisible = false; @override @@ -42,6 +43,7 @@ class _PanelBehindKeyboardDemoState extends State { @override void dispose() { + _isImeConnected.dispose(); _composer.dispose(); _focusNode.dispose(); super.dispose(); @@ -196,6 +198,7 @@ class _PanelBehindKeyboardDemoState extends State { Widget _buildTopPanelToggle(BuildContext context) { return KeyboardPanelScaffold( controller: _keyboardPanelController, + isImeConnected: _isImeConnected, toolbarBuilder: _buildTopPanel, keyboardPanelBuilder: _buildKeyboardPanel, contentBuilder: (context, wantsToShowKeyboardPanel) { 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 4aa60f493..6fd18dbdc 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 @@ -15,6 +15,8 @@ class _MobileChatDemoState extends State { late final KeyboardPanelController _keyboardPanelController; final SoftwareKeyboardController _softwareKeyboardController = SoftwareKeyboardController(); + final _imeConnectionNotifier = ValueNotifier(false); + _Panel? _visiblePanel; @override @@ -30,6 +32,7 @@ class _MobileChatDemoState extends State { @override void dispose() { + _imeConnectionNotifier.dispose(); _keyboardPanelController.dispose(); _focusNode.dispose(); super.dispose(); @@ -94,6 +97,7 @@ class _MobileChatDemoState extends State { padding: const EdgeInsets.only(top: 16, bottom: 24), child: KeyboardPanelScaffold( controller: _keyboardPanelController, + isImeConnected: _imeConnectionNotifier, toolbarBuilder: _buildKeyboardToolbar, keyboardPanelBuilder: (context) { switch (_visiblePanel) { @@ -125,6 +129,7 @@ class _MobileChatDemoState extends State { clearSelectionWhenEditorLosesFocus: false, clearSelectionWhenImeConnectionCloses: false, ), + isImeConnected: _imeConnectionNotifier, ), ], ); @@ -160,6 +165,11 @@ class _MobileChatDemoState extends State { 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, @@ -180,7 +190,7 @@ enum _Panel { class _PanelButton extends StatelessWidget { const _PanelButton({ required this.icon, - required this.isActive, + this.isActive = false, required this.onPressed, }); @@ -235,3 +245,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/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..b8c43e5c3 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. /// @@ -266,6 +274,7 @@ class SuperEditorImeInteractorState extends State impl if (_imeConnection.value == null) { _documentImeConnection.value = null; widget.imeOverrides?.client = null; + widget.isImeConnected?.value = false; return; } @@ -273,6 +282,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 index ac8562f92..c949f113d 100644 --- a/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart +++ b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart @@ -1,3 +1,4 @@ +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'; @@ -34,6 +35,7 @@ class KeyboardPanelScaffold extends StatefulWidget { const KeyboardPanelScaffold({ super.key, required this.controller, + required this.isImeConnected, required this.toolbarBuilder, required this.keyboardPanelBuilder, required this.contentBuilder, @@ -42,6 +44,12 @@ class KeyboardPanelScaffold extends StatefulWidget { /// 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; @@ -130,6 +138,8 @@ class _KeyboardPanelScaffoldState extends State widget.controller.attach(this); + widget.isImeConnected.addListener(_onImeConnectionChange); + onNextFrame((ts) => _overlayPortalController.show()); } @@ -147,16 +157,28 @@ class _KeyboardPanelScaffoldState extends State oldWidget.controller.detach(); widget.controller.attach(this); } + + if (widget.isImeConnected != oldWidget.isImeConnected) { + oldWidget.isImeConnected.removeListener(_onImeConnectionChange); + widget.isImeConnected.addListener(_onImeConnectionChange); + } } @override void dispose() { + widget.isImeConnected.removeListener(_onImeConnectionChange); + widget.controller.detach(); _panelExitAnimation.removeListener(_updatePanelForExitAnimation); _panelExitAnimation.dispose(); - _overlayPortalController.hide(); + 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(); } @@ -171,6 +193,16 @@ class _KeyboardPanelScaffoldState extends State _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; diff --git a/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart b/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart index 796d53942..e1418fcfe 100644 --- a/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart +++ b/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.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'; +import '../test_tools_user_input.dart'; void main() { group('Keyboard panel scaffold', () { @@ -287,9 +287,11 @@ Future _pumpTestApp( WidgetTester tester, { KeyboardPanelController? controller, SoftwareKeyboardController? softwareKeyboardController, + ValueNotifier? isImeConnected, }) async { final keyboardController = softwareKeyboardController ?? SoftwareKeyboardController(); final keyboardPanelController = controller ?? KeyboardPanelController(keyboardController); + final imeConnectionNotifier = isImeConnected ?? ValueNotifier(false); await tester // .createDocument() @@ -297,13 +299,15 @@ Future _pumpTestApp( .withSoftwareKeyboardController(keyboardController) .withCustomWidgetTreeBuilder( (superEditor) => MaterialApp( - home: _SoftwareKeyboardHeightSimulator( + home: SoftwareKeyboardHeightSimulator( tester: tester, keyboardHeight: _keyboardHeight, + animateKeyboard: true, child: Scaffold( resizeToAvoidBottomInset: false, body: KeyboardPanelScaffold( controller: keyboardPanelController, + isImeConnected: imeConnectionNotifier, contentBuilder: (context, isKeyboardPanelVisible) => superEditor, toolbarBuilder: (context, isKeyboardPanelVisible) => const SizedBox( key: _aboveKeyboardPanelKey, @@ -321,129 +325,6 @@ Future _pumpTestApp( .pump(); } -/// 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, - required this.keyboardHeight, - required this.child, - }); - - final WidgetTester tester; - - /// The desired height of the software keyboard. - final double keyboardHeight; - - final Widget child; - - @override - State<_SoftwareKeyboardHeightSimulator> createState() => _SoftwareKeyboardHeightSimulatorState(); -} - -class _SoftwareKeyboardHeightSimulatorState extends State<_SoftwareKeyboardHeightSimulator> - with SingleTickerProviderStateMixin { - late AnimationController _animationController; - - @override - void initState() { - super.initState(); - _animationController = AnimationController( - duration: const Duration(milliseconds: 600), - vsync: this, - ); - - _setupPlatformMethodInterception(); - } - - @override - void didUpdateWidget(covariant _SoftwareKeyboardHeightSimulator oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.tester != oldWidget.tester) { - _setupPlatformMethodInterception(); - } - } - - @override - void dispose() { - _animationController.dispose(); - super.dispose(); - } - - void _showKeyboard() { - if (_animationController.isForwardOrCompleted) { - // The keyboard is either fully visible or animating its entrance. - return; - } - - _animationController.forward(); - } - - void _hideKeyboard() { - if (const [AnimationStatus.dismissed, AnimationStatus.reverse].contains(_animationController.status)) { - // The keyboard is either hidden or animating its exit. - 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) { - final mediaQuery = MediaQuery.of(context); - - return AnimatedBuilder( - animation: _animationController, - builder: (context, _) { - return MediaQuery( - data: mediaQuery.copyWith( - viewInsets: mediaQuery.viewInsets.copyWith( - bottom: widget.keyboardHeight * _animationController.value, - ), - ), - child: widget.child, - ); - }, - ); - } -} - const _keyboardHeight = 400.0; const _aboveKeyboardPanelKey = ValueKey('aboveKeyboardPanel'); 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..5af69bafa 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,13 @@ 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) { + _config.simulateSoftwareKeyboardInsets = doSimulation; + 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 +235,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 [], @@ -579,6 +593,12 @@ class _TestSuperEditorState extends State<_TestSuperEditor> { ); } + testSuperEditor = SoftwareKeyboardHeightSimulator( + tester: widget.testConfiguration.tester, + isEnabled: widget.testConfiguration.simulateSoftwareKeyboardInsets, + child: testSuperEditor, + ); + return testSuperEditor; } @@ -597,6 +617,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 +686,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 +724,11 @@ class SuperEditorTestConfiguration { Color? androidCaretColor; SoftwareKeyboardController? softwareKeyboardController; + bool simulateSoftwareKeyboardInsets = 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..95c80d782 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 = 200, + 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 { From b1bd4b1605d591e92f6ab0d9de3f26c910fa00e4 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Mon, 14 Oct 2024 15:09:00 -0700 Subject: [PATCH 11/13] Added test for auto-closing panel when IME closes --- .../supereditor_ime_interactor.dart | 9 +++ .../keyboard_panel_scaffold_test.dart | 68 +++++++++++++------ .../super_editor/supereditor_test_tools.dart | 58 ++++++++++------ super_editor/test/test_tools_user_input.dart | 2 +- 4 files changed, 96 insertions(+), 41 deletions(-) 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 b8c43e5c3..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 @@ -185,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 diff --git a/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart b/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart index e1418fcfe..37e6434e0 100644 --- a/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart +++ b/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart @@ -5,7 +5,6 @@ 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'; -import '../test_tools_user_input.dart'; void main() { group('Keyboard panel scaffold', () { @@ -16,7 +15,7 @@ void main() { expect(find.byKey(_keyboardPanelKey), findsNothing); }); - testWidgetsOnMobile('shows above-keyboard panel at the bottom when there is no keyboard', (tester) async { + testWidgetsOnMobile('shows keyboard panel at the bottom when there is no keyboard', (tester) async { final softwareKeyboardController = SoftwareKeyboardController(); final controller = KeyboardPanelController(softwareKeyboardController); @@ -47,7 +46,7 @@ void main() { expect(find.byKey(_keyboardPanelKey), findsNothing); }); - testWidgetsOnMobile('shows above-keyboard panel above the keyboard', (tester) async { + testWidgetsOnMobile('shows keyboard toolbar above the keyboard', (tester) async { final softwareKeyboardController = SoftwareKeyboardController(); final controller = KeyboardPanelController(softwareKeyboardController); @@ -64,14 +63,14 @@ void main() { // 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 aboce the software keyboard. + // Ensure the above-keyboard panel sits above the software keyboard. expect( tester.getBottomLeft(find.byKey(_aboveKeyboardPanelKey)).dy, equals(tester.getSize(find.byType(MaterialApp)).height - _keyboardHeight), ); }); - testWidgetsOnMobile('shows above-keyboard panel above the keyboard when toggling panels and showing the keyboard', + testWidgetsOnMobile('shows keyboard toolbar above the keyboard when toggling panels and showing the keyboard', (tester) async { final softwareKeyboardController = SoftwareKeyboardController(); final controller = KeyboardPanelController(softwareKeyboardController); @@ -237,8 +236,39 @@ void main() { ); }); - testWidgetsOnMobile('shows above-keyboard panel at the bottom when closing the panel and the keyboard', - (tester) async { + testWidgetsOnMobile('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); + }); + + testWidgetsOnMobile('shows keyboard toolbar at the bottom when closing the panel and the keyboard', (tester) async { final softwareKeyboardController = SoftwareKeyboardController(); final controller = KeyboardPanelController(softwareKeyboardController); @@ -281,7 +311,7 @@ void main() { /// 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 comunicates with the IME to show/hide +/// the `MediaQuery` view insets when the app communicates with the IME to show/hide /// the software keyboard. Future _pumpTestApp( WidgetTester tester, { @@ -297,34 +327,34 @@ Future _pumpTestApp( .createDocument() .withLongDoc() .withSoftwareKeyboardController(keyboardController) + .withImeConnectionNotifier(imeConnectionNotifier) + .simulateSoftwareKeyboardInsets(true) .withCustomWidgetTreeBuilder( (superEditor) => MaterialApp( - home: SoftwareKeyboardHeightSimulator( - tester: tester, - keyboardHeight: _keyboardHeight, - animateKeyboard: true, - child: Scaffold( - resizeToAvoidBottomInset: false, - body: KeyboardPanelScaffold( + home: Scaffold( + resizeToAvoidBottomInset: false, + body: Builder(builder: (context) { + return KeyboardPanelScaffold( controller: keyboardPanelController, isImeConnected: imeConnectionNotifier, contentBuilder: (context, isKeyboardPanelVisible) => superEditor, - toolbarBuilder: (context, isKeyboardPanelVisible) => const SizedBox( + toolbarBuilder: (context, isKeyboardPanelVisible) => Container( key: _aboveKeyboardPanelKey, height: 54, + color: Colors.blue, ), keyboardPanelBuilder: (context) => const ColoredBox( key: _keyboardPanelKey, color: Colors.red, ), - ), - ), + ); + }), ), ), ) .pump(); } -const _keyboardHeight = 400.0; +const _keyboardHeight = 300.0; const _aboveKeyboardPanelKey = ValueKey('aboveKeyboardPanel'); const _keyboardPanelKey = ValueKey('keyboardPanel'); diff --git a/super_editor/test/super_editor/supereditor_test_tools.dart b/super_editor/test/super_editor/supereditor_test_tools.dart index 5af69bafa..1528ffe51 100644 --- a/super_editor/test/super_editor/supereditor_test_tools.dart +++ b/super_editor/test/super_editor/supereditor_test_tools.dart @@ -210,8 +210,13 @@ class TestSuperEditorConfigurator { /// 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) { - _config.simulateSoftwareKeyboardInsets = doSimulation; + TestSuperEditorConfigurator simulateSoftwareKeyboardInsets( + bool doSimulation, { + bool animateKeyboard = false, + }) { + _config + ..simulateSoftwareKeyboardInsets = doSimulation + ..animateSimulatedSoftwareKeyboard = animateKeyboard; return this; } @@ -468,7 +473,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, @@ -479,24 +486,38 @@ 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, + animateKeyboard: _config.animateSimulatedSoftwareKeyboard, + child: child, + ); + } + /// Constrains the width and height of the given [superEditor], based on configurations /// in this class. Widget _buildConstrainedContent(Widget superEditor) { @@ -593,12 +614,6 @@ class _TestSuperEditorState extends State<_TestSuperEditor> { ); } - testSuperEditor = SoftwareKeyboardHeightSimulator( - tester: widget.testConfiguration.tester, - isEnabled: widget.testConfiguration.simulateSoftwareKeyboardInsets, - child: testSuperEditor, - ); - return testSuperEditor; } @@ -725,6 +740,7 @@ class SuperEditorTestConfiguration { SoftwareKeyboardController? softwareKeyboardController; bool simulateSoftwareKeyboardInsets = false; + bool animateSimulatedSoftwareKeyboard = false; SuperEditorImePolicies? imePolicies; SuperEditorImeConfiguration? imeConfiguration; DeltaTextInputClientDecorator? imeOverrides; diff --git a/super_editor/test/test_tools_user_input.dart b/super_editor/test/test_tools_user_input.dart index 95c80d782..efbfaddbc 100644 --- a/super_editor/test/test_tools_user_input.dart +++ b/super_editor/test/test_tools_user_input.dart @@ -46,7 +46,7 @@ class SoftwareKeyboardHeightSimulator extends StatefulWidget { required this.tester, this.isEnabled = true, this.enableForAllPlatforms = false, - this.keyboardHeight = 200, + this.keyboardHeight = 300, this.animateKeyboard = false, required this.child, }); From 0b385239b708620e7a512d135c5441e553a19bf7 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Fri, 18 Oct 2024 10:54:00 -0700 Subject: [PATCH 12/13] WIP: Filling out tests for tablets --- .../demos/mobile_chat/demo_mobile_chat.dart | 233 ++--- .../keyboard_panel_scaffold.dart | 216 +++-- .../keyboard_panel_scaffold_test.dart | 809 +++++++++++------- .../super_editor/supereditor_test_tools.dart | 4 + 4 files changed, 825 insertions(+), 437 deletions(-) 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 6fd18dbdc..fce472f50 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,7 +9,9 @@ class MobileChatDemo extends StatefulWidget { } class _MobileChatDemoState extends State { - final FocusNode _focusNode = FocusNode(); + final FocusNode _screenFocusNode = FocusNode(); + + final FocusNode _editorFocusNode = FocusNode(); late final Editor _editor; late final KeyboardPanelController _keyboardPanelController; @@ -28,13 +30,18 @@ class _MobileChatDemoState extends State { _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(); - _focusNode.dispose(); + _editorFocusNode.dispose(); + _screenFocusNode.dispose(); super.dispose(); } @@ -56,7 +63,15 @@ class _MobileChatDemoState extends State { child: Stack( children: [ Positioned.fill( - child: ColoredBox(color: Colors.white), + child: GestureDetector( + onTap: () { + _screenFocusNode.requestFocus(); + }, + child: Focus( + focusNode: _screenFocusNode, + child: ColoredBox(color: Colors.white), + ), + ), ), Positioned( left: 0, @@ -70,73 +85,83 @@ class _MobileChatDemoState extends State { } 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), - ), - 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, + return Opacity( + opacity: 0.5, + child: KeyboardPanelScaffold( + controller: _keyboardPanelController, + isImeConnected: _imeConnectionNotifier, + toolbarBuilder: _buildKeyboardToolbar, + keyboardPanelBuilder: (context) { + switch (_visiblePanel) { + case _Panel.panel1: + return Row( + children: [ + Spacer(), + Expanded( + child: Container( + color: Colors.blue.withOpacity(0.5), + 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.yellow, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(24), + topRight: Radius.circular(24), ), - ], - ), - padding: const EdgeInsets.only(top: 16, bottom: 24), - 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( + 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, + ), + ], + ), + padding: const EdgeInsets.only(top: 16), + child: ColoredBox( + color: Colors.red, + child: CustomScrollView( shrinkWrap: true, slivers: [ - SuperEditor( - editor: _editor, - focusNode: _focusNode, - softwareKeyboardController: _softwareKeyboardController, - shrinkWrap: true, - stylesheet: _chatStylesheet, - selectionPolicies: const SuperEditorSelectionPolicies( - clearSelectionWhenEditorLosesFocus: false, - clearSelectionWhenImeConnectionCloses: false, + SliverPadding( + padding: const EdgeInsets.only(bottom: 24), + sliver: SuperEditor( + editor: _editor, + focusNode: _editorFocusNode, + softwareKeyboardController: _softwareKeyboardController, + shrinkWrap: true, + stylesheet: _chatStylesheet, + selectionPolicies: const SuperEditorSelectionPolicies( + clearSelectionWhenEditorLosesFocus: false, + clearSelectionWhenImeConnectionCloses: false, + ), + isImeConnected: _imeConnectionNotifier, ), - isImeConnected: _imeConnectionNotifier, ), ], - ); - }, - ), - ), - ], + ), + ), + ); + }, + ), ); } @@ -145,39 +170,49 @@ class _MobileChatDemoState extends State { _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), + return Row( + children: [ + const SizedBox(width: 200), + 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), + ], + ), + ), ), - const SizedBox(width: 24), - ], - ), + ), + ], ); } } @@ -217,16 +252,18 @@ class _PanelButton extends StatelessWidget { } 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) { diff --git a/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart index c949f113d..275552081 100644 --- a/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart +++ b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:super_editor/src/default_editor/document_ime/document_input_ime.dart'; @@ -38,6 +40,7 @@ class KeyboardPanelScaffold extends StatefulWidget { required this.isImeConnected, required this.toolbarBuilder, required this.keyboardPanelBuilder, + this.fallbackPanelHeight = 250, required this.contentBuilder, }); @@ -56,6 +59,11 @@ class KeyboardPanelScaffold extends StatefulWidget { /// 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 @@ -108,9 +116,9 @@ class _KeyboardPanelScaffoldState extends State /// Shows/hides the [OverlayPortal] containing the keyboard panel and above-keyboard panel. final OverlayPortalController _overlayPortalController = OverlayPortalController(); - bool get _wantsToShowAboveKeyboardPanel => + bool get _wantsToShowToolbar => widget.controller.toolbarVisibility == KeyboardToolbarVisibility.visible || - (widget.controller.toolbarVisibility == KeyboardToolbarVisibility.auto && _keyboardHeight.value > 0); + (widget.controller.toolbarVisibility == KeyboardToolbarVisibility.auto && widget.isImeConnected.value); final _toolbarKey = GlobalKey(); @@ -140,13 +148,19 @@ class _KeyboardPanelScaffoldState extends State widget.isImeConnected.addListener(_onImeConnectionChange); - onNextFrame((ts) => _overlayPortalController.show()); + _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(); + print("didChangeDependencies()"); _updateKeyboardHeightForCurrentViewInsets(); } @@ -164,6 +178,15 @@ class _KeyboardPanelScaffoldState extends State } } + @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); @@ -209,6 +232,7 @@ class _KeyboardPanelScaffoldState extends State KeyboardToolbarVisibility _toolbarVisibility = KeyboardToolbarVisibility.auto; @override set toolbarVisibility(KeyboardToolbarVisibility value) { + print("Setting toolbar visibility: $value"); if (value == _toolbarVisibility) { return; } @@ -241,15 +265,19 @@ class _KeyboardPanelScaffoldState extends State /// Shows the toolbar that's mounted to the top of the keyboard area. @override void showToolbar() { - _toolbarVisibility = KeyboardToolbarVisibility.visible; - _isToolbarVisible = true; + setState(() { + _toolbarVisibility = KeyboardToolbarVisibility.visible; + _isToolbarVisible = true; + }); } /// Hides the toolbar that's mounted to the top of the keyboard area. @override void hideToolbar() { - _toolbarVisibility = KeyboardToolbarVisibility.hidden; - _isToolbarVisible = false; + setState(() { + _toolbarVisibility = KeyboardToolbarVisibility.hidden; + _isToolbarVisible = false; + }); } /// Whether the software keyboard should be displayed, instead of the keyboard panel. @@ -270,16 +298,20 @@ class _KeyboardPanelScaffoldState extends State /// Shows the software keyboard, if it's hidden. @override void showSoftwareKeyboard() { - _wantsToShowKeyboardPanel = false; - _wantsToShowSoftwareKeyboard = true; - _softwareKeyboardController!.open(); + setState(() { + _wantsToShowKeyboardPanel = false; + _wantsToShowSoftwareKeyboard = true; + _softwareKeyboardController!.open(); + }); } /// Hides (doesn't close) the software keyboard, if it's open. @override void hideSoftwareKeyboard() { - _wantsToShowSoftwareKeyboard = false; - _softwareKeyboardController!.hide(); + setState(() { + _wantsToShowSoftwareKeyboard = false; + _softwareKeyboardController!.hide(); + }); _maybeAnimatePanelClosed(); } @@ -292,24 +324,30 @@ class _KeyboardPanelScaffoldState extends State /// software keyboard, if it's open. @override void showKeyboardPanel() { - _wantsToShowKeyboardPanel = true; - _wantsToShowSoftwareKeyboard = false; - _softwareKeyboardController!.hide(); + setState(() { + _wantsToShowKeyboardPanel = true; + _wantsToShowSoftwareKeyboard = false; + _softwareKeyboardController!.hide(); + }); } /// Hides the keyboard panel, if it's open. @override void hideKeyboardPanel() { - _wantsToShowKeyboardPanel = false; + 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() { - _wantsToShowKeyboardPanel = false; - _wantsToShowSoftwareKeyboard = false; - _softwareKeyboardController!.close(); + setState(() { + _wantsToShowKeyboardPanel = false; + _wantsToShowSoftwareKeyboard = false; + _softwareKeyboardController!.close(); + }); _maybeAnimatePanelClosed(); } @@ -329,20 +367,23 @@ class _KeyboardPanelScaffoldState extends State // the current software keyboard height. void _updateKeyboardHeightForCurrentViewInsets() { final newInsets = MediaQuery.of(context).viewInsets; + print("New insets: $newInsets"); final newBottomInset = newInsets.bottom; final isKeyboardCollapsing = newBottomInset < _latestViewInsets.bottom; + print(" - is keyboard collapsing? $isKeyboardCollapsing"); - final isClosing = newBottomInset < _latestViewInsets.bottom; - if (_isKeyboardOpen && isClosing) { + if (_isKeyboardOpen && isKeyboardCollapsing) { // The keyboard went from open to closed. Update our cached state. _isKeyboardOpen = false; - } else if (!_isKeyboardOpen && !isClosing) { + } 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; + // print("Setting _keyboardHeight to $newBottomInset"); + // _keyboardHeight.value = newBottomInset; if (newBottomInset > _maxBottomInsets) { // The keyboard is expanding. @@ -383,27 +424,65 @@ class _KeyboardPanelScaffoldState extends State } final toolbarSize = (_toolbarKey.currentContext?.findRenderObject() as RenderBox?)?.size; - keyboardSafeAreaData.bottomInsets = _keyboardHeight.value + (toolbarSize?.height ?? 0); + keyboardSafeAreaData.geometry = keyboardSafeAreaData.geometry.copyWith( + bottomInsets: _wantsToShowKeyboardPanel // + ? _keyboardPanelHeight + (toolbarSize?.height ?? 0) + : _keyboardHeight.value + (toolbarSize?.height ?? 0), + ); + print( + "Updating safe area - toolbar height: ${toolbarSize?.height}, keyboard height: ${_keyboardHeight.value}, total bottom insets: ${keyboardSafeAreaData.geometry.bottomInsets}"); + } + + double get _keyboardPanelHeight { + return _wantsToShowKeyboardPanel // + ? _keyboardHeight.value < 100 // + ? widget.fallbackPanelHeight + : _keyboardHeight.value + : 0.0; } @override Widget build(BuildContext context) { + print("Scaffold - build()"); 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; + print("Fake keyboard height: $fakeKeyboardHeight"); + return OverlayPortal( controller: _overlayPortalController, overlayChildBuilder: (context) { return ValueListenableBuilder( valueListenable: _keyboardHeight, builder: (context, currentHeight, child) { - if (!_wantsToShowAboveKeyboardPanel && !wantsToShowKeyboardPanel) { + print("Building scaffold"); + print("Is IME connected? ${widget.isImeConnected.value}"); + print("Keyboard height: ${_keyboardHeight.value}"); + print("Toolbar visibility: ${widget.controller.toolbarVisibility}"); + print("Wants to show toolbar: $_wantsToShowToolbar"); + print("Wants to show panel: $_wantsToShowKeyboardPanel"); + if (wantsToShowKeyboardPanel) { + print("Building keyboard panel. Keyboard height: ${_keyboardHeight.value}"); + } + + 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, @@ -411,7 +490,7 @@ class _KeyboardPanelScaffoldState extends State child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (_wantsToShowAboveKeyboardPanel) + if (_wantsToShowToolbar) KeyedSubtree( key: _toolbarKey, child: widget.toolbarBuilder( @@ -420,7 +499,9 @@ class _KeyboardPanelScaffoldState extends State ), ), SizedBox( - height: _keyboardHeight.value, + height: !_wantsToShowKeyboardPanel || _keyboardHeight.value > 100 + ? _keyboardHeight.value + : fakeKeyboardHeight, child: wantsToShowKeyboardPanel ? widget.keyboardPanelBuilder(context) : null, ), ], @@ -429,9 +510,13 @@ class _KeyboardPanelScaffoldState extends State }, ); }, - child: widget.contentBuilder( - context, - _wantsToShowKeyboardPanel, + child: Padding( + // padding: EdgeInsets.only(bottom: fakeKeyboardHeight), + padding: EdgeInsets.zero, + child: widget.contentBuilder( + context, + _wantsToShowKeyboardPanel, + ), ), ); } @@ -587,12 +672,13 @@ enum KeyboardToolbarVisibility { /// The padding in [KeyboardScaffoldSafeArea] is set by a descendant [KeyboardPanelScaffold] /// in the widget tree. class KeyboardScaffoldSafeArea extends StatefulWidget { - static KeyboardSafeAreaGeometry of(BuildContext context) { + static KeyboardScaffoldSafeAreaMutator of(BuildContext context) { return maybeOf(context)!; } - static KeyboardSafeAreaGeometry? maybeOf(BuildContext context) { - return context.dependOnInheritedWidgetOfExactType<_InheritedKeyboardScaffoldSafeArea>()?.keyboardSafeAreaData; + static KeyboardScaffoldSafeAreaMutator? maybeOf(BuildContext context) { + context.dependOnInheritedWidgetOfExactType<_InheritedKeyboardScaffoldSafeArea>(); + return context.findAncestorStateOfType<_KeyboardScaffoldSafeAreaState>(); } const KeyboardScaffoldSafeArea({ @@ -606,26 +692,43 @@ class KeyboardScaffoldSafeArea extends StatefulWidget { State createState() => _KeyboardScaffoldSafeAreaState(); } -class _KeyboardScaffoldSafeAreaState extends State { - final KeyboardSafeAreaGeometry _keyboardSafeAreaData = KeyboardSafeAreaGeometry(); +class _KeyboardScaffoldSafeAreaState extends State + implements KeyboardScaffoldSafeAreaMutator { + KeyboardSafeAreaGeometry _keyboardSafeAreaData = KeyboardSafeAreaGeometry(); + + @override + KeyboardSafeAreaGeometry get geometry => _keyboardSafeAreaData; + + @override + set geometry(KeyboardSafeAreaGeometry geometry) { + print("Keyboard safe area - setting bottom insets to: ${geometry.bottomInsets}"); + if (geometry == _keyboardSafeAreaData) { + print(" - that's already our bottom insets. Ignoring."); + return; + } + + setState(() { + _keyboardSafeAreaData = geometry; + }); + } @override Widget build(BuildContext context) { return _InheritedKeyboardScaffoldSafeArea( keyboardSafeAreaData: _keyboardSafeAreaData, - child: ListenableBuilder( - listenable: _keyboardSafeAreaData, - builder: (context, _) { - return Padding( - padding: EdgeInsets.only(bottom: _keyboardSafeAreaData.bottomInsets), - child: widget.child, - ); - }, + 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, @@ -642,15 +745,26 @@ class _InheritedKeyboardScaffoldSafeArea extends InheritedWidget { /// Insets applied by a [KeyboardPanelScaffold] to an ancestor [KeyboardScaffoldSafeArea] /// to deal with the presence or absence of the software keyboard. -class KeyboardSafeAreaGeometry with ChangeNotifier { - KeyboardSafeAreaGeometry({ - double bottomInsets = 0.0, - }) : _bottomInsets = bottomInsets; +class KeyboardSafeAreaGeometry { + const KeyboardSafeAreaGeometry({ + this.bottomInsets = 0, + }); + + final double bottomInsets; - double get bottomInsets => _bottomInsets; - double _bottomInsets; - set bottomInsets(double value) { - _bottomInsets = value; - notifyListeners(); + 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/test/infrastructure/keyboard_panel_scaffold_test.dart b/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart index 37e6434e0..bd1c089eb 100644 --- a/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart +++ b/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart @@ -7,303 +7,436 @@ import 'package:super_editor/super_editor.dart'; import '../super_editor/supereditor_test_tools.dart'; void main() { - group('Keyboard panel scaffold', () { - testWidgetsOnMobile('does not show panel upon initialization', (tester) async { - await _pumpTestApp(tester); + 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 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, + ); + }); }); - testWidgetsOnMobile('shows keyboard panel 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 panel. - controller.showToolbar(); - await tester.pump(); - - // Ensure the above-keyboard panel sits at the bottom of the screen. - expect( - tester.getBottomLeft(find.byKey(_aboveKeyboardPanelKey)).dy, - equals(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 correctly. + expect(find.byKey(_keyboardPanelKey), findsOneWidget); + + final panelSize = tester.getSize(find.byKey(_keyboardPanelKey)); + expect(panelSize.height, _keyboardPanelHeightForTabletWithMinimizedKeyboard); + + await expectLater(find.byType(MaterialApp), matchesGoldenFile('deleteme.png')); + + expect( + tester.getBottomLeft(find.byKey(_keyboardPanelKey)).dy, + screenHeight - _minimizedIPadKeyboardHeight, + ); + + // Ensure the toolbar is above the panel. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + screenHeight - (_minimizedIPadKeyboardHeight + _keyboardPanelHeightForTabletWithMinimizedKeyboard), + ); + + // Request to hide the keyboard panel. + controller.hideKeyboardPanel(); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is gone. + expect(find.byKey(_keyboardPanelKey), findsNothing); + }); }); - testWidgetsOnMobile('does not show keyboard panel upon keyboard appearance', (tester) async { - await _pumpTestApp(tester); + group('Android tablets >', () { + testWidgetsOnIPad('shows panel when keyboard is docked', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); - // Place the caret at the beginning of the document to show the software keyboard. - await tester.placeCaretInParagraph('1', 0); + await _pumpTestApp( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + simulatedKeyboardHeight: _expandedAndroidTabletKeyboardHeight, + ); - // Ensure the keyboard panel is not visible. - expect(find.byKey(_keyboardPanelKey), findsNothing); - }); - - testWidgetsOnMobile('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(_aboveKeyboardPanelKey)).dy, - equals(tester.getSize(find.byType(MaterialApp)).height - _keyboardHeight), - ); - }); - - testWidgetsOnMobile('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(_aboveKeyboardPanelKey)).dy, - equals(tester.getSize(find.byType(MaterialApp)).height - _keyboardHeight), - ); - }); - - testWidgetsOnMobile('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); - }); - - testWidgetsOnMobile('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(_keyboardHeight), - ); - - // Ensure the above-keyboard panel sits immediately above the keyboard panel. - expect( - tester.getBottomLeft(find.byKey(_aboveKeyboardPanelKey)).dy, - equals(tester.getSize(find.byType(MaterialApp)).height - _keyboardHeight), - ); - }); - - testWidgetsOnMobile('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); + // 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 immediately above the keyboard. - expect( - tester.getBottomLeft(find.byKey(_aboveKeyboardPanelKey)).dy, - equals(tester.getSize(find.byType(MaterialApp)).height - _keyboardHeight), - ); - }); - - testWidgetsOnMobile('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(_aboveKeyboardPanelKey)).dy, - equals(tester.getSize(find.byType(MaterialApp)).height), - ); - }); - - testWidgetsOnMobile('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(); + // 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); + // Ensure the keyboard panel is visible. + expect(find.byKey(_keyboardPanelKey), findsOneWidget); - // Close the IME connection. - softwareKeyboardController.close(); - await tester.pumpAndSettle(); + final panelSize = tester.getSize(find.byKey(_keyboardPanelKey)); + expect(panelSize.height, _expandedAndroidTabletKeyboardHeight); + }); - // Ensure the keyboard panel is not visible. - expect(find.byKey(_keyboardPanelKey), findsNothing); - }); - - testWidgetsOnMobile('shows keyboard toolbar at the bottom when 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 panel. - controller.showToolbar(); - await tester.pump(); + testWidgetsOnAndroidTablet('shows panel when keyboard is floating or minimized', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); - // Place the caret at the beginning of the document to show the software keyboard. - await tester.placeCaretInParagraph('1', 0); + await _pumpTestApp( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + simulatedKeyboardHeight: _minimizedAndroidTabletKeyboardHeight, + ); - // Request to show the keyboard panel and let the entrance animation run. - controller.showKeyboardPanel(); - await tester.pumpAndSettle(); + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); - // Ensure the keyboard panel is visible. - expect(find.byKey(_keyboardPanelKey), findsOneWidget); + // Request to show the keyboard panel and let the entrance animation run. + controller.showKeyboardPanel(); + await tester.pumpAndSettle(); - // Hide the keyboard panel and the software keyboard. - controller.closeKeyboardAndPanel(); - await tester.pumpAndSettle(); + // Ensure the keyboard panel is visible. + expect(find.byKey(_keyboardPanelKey), findsOneWidget); - // 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(_aboveKeyboardPanelKey)).dy, - tester.getSize(find.byType(MaterialApp)).height, - ); + final panelSize = tester.getSize(find.byKey(_keyboardPanelKey)); + expect(panelSize.height, _keyboardPanelHeightForTabletWithMinimizedKeyboard); + }); }); }); } @@ -318,6 +451,7 @@ Future _pumpTestApp( KeyboardPanelController? controller, SoftwareKeyboardController? softwareKeyboardController, ValueNotifier? isImeConnected, + double simulatedKeyboardHeight = _expandedPhoneKeyboardHeight, }) async { final keyboardController = softwareKeyboardController ?? SoftwareKeyboardController(); final keyboardPanelController = controller ?? KeyboardPanelController(keyboardController); @@ -328,7 +462,10 @@ Future _pumpTestApp( .withLongDoc() .withSoftwareKeyboardController(keyboardController) .withImeConnectionNotifier(imeConnectionNotifier) - .simulateSoftwareKeyboardInsets(true) + .simulateSoftwareKeyboardInsets( + true, + simulatedKeyboardHeight: simulatedKeyboardHeight, + ) .withCustomWidgetTreeBuilder( (superEditor) => MaterialApp( home: Scaffold( @@ -339,13 +476,15 @@ Future _pumpTestApp( isImeConnected: imeConnectionNotifier, contentBuilder: (context, isKeyboardPanelVisible) => superEditor, toolbarBuilder: (context, isKeyboardPanelVisible) => Container( - key: _aboveKeyboardPanelKey, + key: _aboveKeyboardToolbarKey, height: 54, color: Colors.blue, ), - keyboardPanelBuilder: (context) => const ColoredBox( - key: _keyboardPanelKey, - color: Colors.red, + keyboardPanelBuilder: (context) => const SizedBox.expand( + child: ColoredBox( + key: _keyboardPanelKey, + color: Colors.red, + ), ), ); }), @@ -355,6 +494,100 @@ Future _pumpTestApp( .pump(); } -const _keyboardHeight = 300.0; -const _aboveKeyboardPanelKey = ValueKey('aboveKeyboardPanel'); +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/supereditor_test_tools.dart b/super_editor/test/super_editor/supereditor_test_tools.dart index 1528ffe51..586784136 100644 --- a/super_editor/test/super_editor/supereditor_test_tools.dart +++ b/super_editor/test/super_editor/supereditor_test_tools.dart @@ -212,10 +212,12 @@ class TestSuperEditorConfigurator { /// 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; } @@ -513,6 +515,7 @@ class TestSuperEditorConfigurator { return SoftwareKeyboardHeightSimulator( tester: _config.tester, isEnabled: _config.simulateSoftwareKeyboardInsets, + keyboardHeight: _config.simulatedKeyboardHeight, animateKeyboard: _config.animateSimulatedSoftwareKeyboard, child: child, ); @@ -740,6 +743,7 @@ class SuperEditorTestConfiguration { SoftwareKeyboardController? softwareKeyboardController; bool simulateSoftwareKeyboardInsets = false; + double simulatedKeyboardHeight = 300; bool animateSimulatedSoftwareKeyboard = false; SuperEditorImePolicies? imePolicies; SuperEditorImeConfiguration? imeConfiguration; From ea819848bbfb12ecbe722826f884262eb36366e1 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Sun, 20 Oct 2024 13:30:23 -0700 Subject: [PATCH 13/13] Added some tablet tests for minimized keyboard area --- .../demos/mobile_chat/demo_mobile_chat.dart | 54 ++++++++----------- .../keyboard_panel_scaffold.dart | 24 --------- .../keyboard_panel_scaffold_test.dart | 47 +++++++++++++--- 3 files changed, 63 insertions(+), 62 deletions(-) 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 fce472f50..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 @@ -91,19 +91,13 @@ class _MobileChatDemoState extends State { controller: _keyboardPanelController, isImeConnected: _imeConnectionNotifier, toolbarBuilder: _buildKeyboardToolbar, + fallbackPanelHeight: MediaQuery.sizeOf(context).height / 3, keyboardPanelBuilder: (context) { switch (_visiblePanel) { case _Panel.panel1: - return Row( - children: [ - Spacer(), - Expanded( - child: Container( - color: Colors.blue.withOpacity(0.5), - height: double.infinity, - ), - ), - ], + return Container( + color: Colors.blue, + height: double.infinity, ); case _Panel.panel2: return Container( @@ -117,7 +111,7 @@ class _MobileChatDemoState extends State { contentBuilder: (context, isKeyboardVisible) { return Container( decoration: BoxDecoration( - color: Colors.yellow, + color: Colors.white, borderRadius: BorderRadius.only( topLeft: Radius.circular(24), topRight: Radius.circular(24), @@ -136,28 +130,25 @@ class _MobileChatDemoState extends State { ], ), padding: const EdgeInsets.only(top: 16), - child: ColoredBox( - color: Colors.red, - 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: false, - clearSelectionWhenImeConnectionCloses: false, - ), - isImeConnected: _imeConnectionNotifier, + 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, ), - ], - ), + ), + ], ), ); }, @@ -172,7 +163,6 @@ class _MobileChatDemoState extends State { return Row( children: [ - const SizedBox(width: 200), Expanded( child: Opacity( opacity: 0.5, diff --git a/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart index 275552081..8974909b6 100644 --- a/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart +++ b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart @@ -1,5 +1,3 @@ -import 'dart:math'; - import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:super_editor/src/default_editor/document_ime/document_input_ime.dart'; @@ -160,7 +158,6 @@ class _KeyboardPanelScaffoldState extends State void didChangeDependencies() { super.didChangeDependencies(); - print("didChangeDependencies()"); _updateKeyboardHeightForCurrentViewInsets(); } @@ -232,7 +229,6 @@ class _KeyboardPanelScaffoldState extends State KeyboardToolbarVisibility _toolbarVisibility = KeyboardToolbarVisibility.auto; @override set toolbarVisibility(KeyboardToolbarVisibility value) { - print("Setting toolbar visibility: $value"); if (value == _toolbarVisibility) { return; } @@ -367,10 +363,8 @@ class _KeyboardPanelScaffoldState extends State // the current software keyboard height. void _updateKeyboardHeightForCurrentViewInsets() { final newInsets = MediaQuery.of(context).viewInsets; - print("New insets: $newInsets"); final newBottomInset = newInsets.bottom; final isKeyboardCollapsing = newBottomInset < _latestViewInsets.bottom; - print(" - is keyboard collapsing? $isKeyboardCollapsing"); if (_isKeyboardOpen && isKeyboardCollapsing) { // The keyboard went from open to closed. Update our cached state. @@ -382,8 +376,6 @@ class _KeyboardPanelScaffoldState extends State } _latestViewInsets = newInsets; - // print("Setting _keyboardHeight to $newBottomInset"); - // _keyboardHeight.value = newBottomInset; if (newBottomInset > _maxBottomInsets) { // The keyboard is expanding. @@ -429,8 +421,6 @@ class _KeyboardPanelScaffoldState extends State ? _keyboardPanelHeight + (toolbarSize?.height ?? 0) : _keyboardHeight.value + (toolbarSize?.height ?? 0), ); - print( - "Updating safe area - toolbar height: ${toolbarSize?.height}, keyboard height: ${_keyboardHeight.value}, total bottom insets: ${keyboardSafeAreaData.geometry.bottomInsets}"); } double get _keyboardPanelHeight { @@ -443,7 +433,6 @@ class _KeyboardPanelScaffoldState extends State @override Widget build(BuildContext context) { - print("Scaffold - build()"); 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 @@ -455,7 +444,6 @@ class _KeyboardPanelScaffoldState extends State ? widget.fallbackPanelHeight : 0.0 : 0.0; - print("Fake keyboard height: $fakeKeyboardHeight"); return OverlayPortal( controller: _overlayPortalController, @@ -463,16 +451,6 @@ class _KeyboardPanelScaffoldState extends State return ValueListenableBuilder( valueListenable: _keyboardHeight, builder: (context, currentHeight, child) { - print("Building scaffold"); - print("Is IME connected? ${widget.isImeConnected.value}"); - print("Keyboard height: ${_keyboardHeight.value}"); - print("Toolbar visibility: ${widget.controller.toolbarVisibility}"); - print("Wants to show toolbar: $_wantsToShowToolbar"); - print("Wants to show panel: $_wantsToShowKeyboardPanel"); - if (wantsToShowKeyboardPanel) { - print("Building keyboard panel. Keyboard height: ${_keyboardHeight.value}"); - } - if (!_wantsToShowToolbar && !wantsToShowKeyboardPanel) { return const SizedBox.shrink(); } @@ -701,9 +679,7 @@ class _KeyboardScaffoldSafeAreaState extends State @override set geometry(KeyboardSafeAreaGeometry geometry) { - print("Keyboard safe area - setting bottom insets to: ${geometry.bottomInsets}"); if (geometry == _keyboardSafeAreaData) { - print(" - that's already our bottom insets. Ignoring."); return; } diff --git a/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart b/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart index bd1c089eb..071e069ee 100644 --- a/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart +++ b/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart @@ -359,23 +359,21 @@ void main() { controller.showKeyboardPanel(); await tester.pumpAndSettle(); - // Ensure the keyboard panel is visible and positioned correctly. + // 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); - await expectLater(find.byType(MaterialApp), matchesGoldenFile('deleteme.png')); - expect( tester.getBottomLeft(find.byKey(_keyboardPanelKey)).dy, - screenHeight - _minimizedIPadKeyboardHeight, + screenHeight, ); // Ensure the toolbar is above the panel. expect( tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, - screenHeight - (_minimizedIPadKeyboardHeight + _keyboardPanelHeightForTabletWithMinimizedKeyboard), + screenHeight - _keyboardPanelHeightForTabletWithMinimizedKeyboard, ); // Request to hide the keyboard panel. @@ -384,6 +382,12 @@ void main() { // 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, + ); }); }); @@ -427,15 +431,46 @@ void main() { // 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. + // 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, + ); }); }); });