From 4d81ec880995203c38649a8147b4814830c94755 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Mon, 23 Dec 2024 12:13:42 -0800 Subject: [PATCH] [SuperEditor] - Make it possible to open a keyboard panel before the keyboard opens (Resolves #2438) (#2476) --- .../demos/mobile_chat/demo_mobile_chat.dart | 123 ++++- .../example/lib/main_super_editor_chat.dart | 10 - super_editor/example/pubspec.yaml | 1 + .../document_gestures_touch_android.dart | 6 +- .../document_gestures_touch_ios.dart | 12 +- .../lib/src/default_editor/super_editor.dart | 27 ++ .../lib/src/infrastructure/_logging.dart | 2 + .../keyboard_panel_scaffold.dart | 443 ++++++++++++------ super_editor/pubspec.yaml | 1 + .../keyboard_panel_scaffold_test.dart | 51 +- .../super_editor/supereditor_test_tools.dart | 1 + super_editor/test/test_tools_user_input.dart | 165 ------- super_keyboard/analysis_options.yaml | 4 + 13 files changed, 468 insertions(+), 378 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 54974ff137..bce356481f 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 @@ -1,6 +1,8 @@ import 'package:example/demos/mobile_chat/giphy_keyboard_panel.dart'; import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; import 'package:super_editor/super_editor.dart'; +import 'package:super_keyboard/super_keyboard.dart'; /// A UI with a chat message editor at the bottom, and a fake chat conversation /// behind it. @@ -33,17 +35,18 @@ class _MobileChatDemoState extends State { final FocusNode _editorFocusNode = FocusNode(); late final Editor _editor; - late final KeyboardPanelController _keyboardPanelController; + late final KeyboardPanelController<_Panel> _keyboardPanelController; final SoftwareKeyboardController _softwareKeyboardController = SoftwareKeyboardController(); final _imeConnectionNotifier = ValueNotifier(false); - _Panel? _visiblePanel; - @override void initState() { super.initState(); + SuperKeyboard.initLogs(); + initLoggers(Level.ALL, {keyboardPanelLog}); + final document = MutableDocument.empty(); final composer = MutableDocumentComposer(); _editor = createDefaultDocumentEditor(document: document, composer: composer); @@ -64,16 +67,36 @@ class _MobileChatDemoState extends State { super.dispose(); } + void _openPanelFromAppBar() { + // This action is here to verify that we can open keyboard panels + // before opening the keyboard. + + // Focus the editor and place the caret. + _editorFocusNode.requestFocus(); + final document = _editor.context.document; + _editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: document.last.endPosition, + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + + // Open a panel. + _keyboardPanelController.showKeyboardPanel(_Panel.panel1); + } + void _togglePanel(_Panel panel) { - setState(() { - if (_visiblePanel == panel) { - _visiblePanel = null; - _keyboardPanelController.showSoftwareKeyboard(); - } else { - _visiblePanel = panel; - _keyboardPanelController.showKeyboardPanel(); - } - }); + if (_keyboardPanelController.openPanel == panel) { + _keyboardPanelController.showSoftwareKeyboard(); + } else { + _keyboardPanelController.showKeyboardPanel(panel); + } } @override @@ -95,6 +118,18 @@ class _MobileChatDemoState extends State { PreferredSizeWidget _buildAppBar() { return AppBar( + actions: [ + IconButton( + icon: Icon(Icons.open_in_new), + onPressed: _openPanelFromAppBar, + ), + IconButton( + icon: Icon(Icons.settings), + onPressed: () { + Navigator.of(context).pushNamed("/second"); + }, + ), + ], bottom: const TabBar( tabs: [ Tab(icon: Icon(Icons.chat)), @@ -111,6 +146,7 @@ class _MobileChatDemoState extends State { child: GestureDetector( onTap: () { _screenFocusNode.requestFocus(); + _keyboardPanelController.closeKeyboardAndPanel(); }, child: Focus( focusNode: _screenFocusNode, @@ -158,15 +194,15 @@ class _MobileChatDemoState extends State { Widget _buildCommentEditor() { return Opacity( + // Opacity is here so we can easily check what's behind it. opacity: 1.0, - // ^ opacity is for testing, so we can see the chat behind it. - child: KeyboardPanelScaffold( + child: KeyboardPanelScaffold<_Panel>( controller: _keyboardPanelController, isImeConnected: _imeConnectionNotifier, toolbarBuilder: _buildKeyboardToolbar, fallbackPanelHeight: MediaQuery.sizeOf(context).height / 3, - keyboardPanelBuilder: (context) { - switch (_visiblePanel) { + keyboardPanelBuilder: (context, panel) { + switch (panel) { case _Panel.panel1: return Container( color: Colors.blue, @@ -224,10 +260,20 @@ class _MobileChatDemoState extends State { shrinkWrap: true, stylesheet: _chatStylesheet, selectionPolicies: const SuperEditorSelectionPolicies( + openKeyboardWhenTappingExistingSelection: false, clearSelectionWhenEditorLosesFocus: true, clearSelectionWhenImeConnectionCloses: false, ), + imePolicies: SuperEditorImePolicies( + openKeyboardOnGainPrimaryFocus: false, + openKeyboardOnSelectionChange: false, + closeKeyboardOnSelectionLost: false, + ), isImeConnected: _imeConnectionNotifier, + contentTapDelegateFactories: [ + superEditorLaunchLinkTapHandlerFactory, + _tapToFocusEditor, + ], ), ), ], @@ -239,11 +285,14 @@ class _MobileChatDemoState extends State { ); } - Widget _buildKeyboardToolbar(BuildContext context, bool isKeyboardPanelVisible) { - if (!isKeyboardPanelVisible) { - _visiblePanel = null; - } + ContentTapDelegate _tapToFocusEditor(SuperEditorContext editContext) { + return _TapToFocusEditor( + _editorFocusNode, + _keyboardPanelController, + ); + } + Widget _buildKeyboardToolbar(BuildContext context, _Panel? openPanel) { return Row( children: [ Expanded( @@ -258,13 +307,13 @@ class _MobileChatDemoState extends State { const Spacer(), _PanelButton( icon: Icons.text_fields, - isActive: _visiblePanel == _Panel.panel1, + isActive: _keyboardPanelController.openPanel == _Panel.panel1, onPressed: () => _togglePanel(_Panel.panel1), ), const SizedBox(width: 16), _PanelButton( icon: Icons.align_horizontal_left, - isActive: _visiblePanel == _Panel.panel2, + isActive: _keyboardPanelController.openPanel == _Panel.panel2, onPressed: () => _togglePanel(_Panel.panel2), ), const SizedBox(width: 16), @@ -279,7 +328,13 @@ class _MobileChatDemoState extends State { ), const Spacer(), GestureDetector( - onTap: _keyboardPanelController.closeKeyboardAndPanel, + onTap: () { + _keyboardPanelController.closeKeyboardAndPanel(); + + // We need to explicitly unfocus so that the caret doesn't + // keep blinking in the editor. + _editorFocusNode.unfocus(); + }, child: Icon(Icons.keyboard_hide), ), const SizedBox(width: 24), @@ -301,6 +356,28 @@ class _MobileChatDemoState extends State { } } +class _TapToFocusEditor extends ContentTapDelegate { + _TapToFocusEditor( + this.editorFocusNode, + this.keyboardPanelController, + ); + + final FocusNode editorFocusNode; + final KeyboardPanelController keyboardPanelController; + + @override + TapHandlingInstruction onTap(DocumentTapDetails details) { + if (!keyboardPanelController.isSoftwareKeyboardOpen && !keyboardPanelController.isKeyboardPanelOpen) { + // The user tapped on the editor and the software keyboard isn't up, nor is a panel. + // Open the software keyboard. + editorFocusNode.requestFocus(); + keyboardPanelController.showSoftwareKeyboard(); + } + + return TapHandlingInstruction.continueHandling; + } +} + enum _Panel { panel1, panel2, diff --git a/super_editor/example/lib/main_super_editor_chat.dart b/super_editor/example/lib/main_super_editor_chat.dart index 16d7fd21b7..5768c5ec0c 100644 --- a/super_editor/example/lib/main_super_editor_chat.dart +++ b/super_editor/example/lib/main_super_editor_chat.dart @@ -27,16 +27,6 @@ void main() { MaterialApp( routes: { "/": (context) => Scaffold( - appBar: AppBar( - actions: [ - IconButton( - onPressed: () { - Navigator.of(context).pushNamed("/second"); - }, - icon: Icon(Icons.settings), - ), - ], - ), resizeToAvoidBottomInset: false, body: MobileChatDemo(), ), diff --git a/super_editor/example/pubspec.yaml b/super_editor/example/pubspec.yaml index 550b5865e9..43282cdb4e 100644 --- a/super_editor/example/pubspec.yaml +++ b/super_editor/example/pubspec.yaml @@ -53,6 +53,7 @@ dependencies: git: url: https://github.com/superlistapp/super_editor.git path: super_text_layout + super_keyboard: ^0.1.0 follow_the_leader: ^0.0.4+7 overlord: ^0.0.3+5 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 fd4ecc7576..ba6ee46b49 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 @@ -402,6 +402,7 @@ class AndroidDocumentTouchInteractor extends StatefulWidget { required this.document, required this.getDocumentLayout, required this.selection, + this.openKeyboardWhenTappingExistingSelection = true, required this.openSoftwareKeyboard, required this.scrollController, required this.fillViewport, @@ -419,6 +420,9 @@ class AndroidDocumentTouchInteractor extends StatefulWidget { final DocumentLayout Function() getDocumentLayout; final ValueListenable selection; + /// {@macro openKeyboardWhenTappingExistingSelection} + final bool openKeyboardWhenTappingExistingSelection; + /// A callback that should open the software keyboard when invoked. final VoidCallback openSoftwareKeyboard; @@ -780,7 +784,7 @@ class _AndroidDocumentTouchInteractorState extends State selection; + /// {@macro openKeyboardWhenTappingExistingSelection} + final bool openKeyboardWhenTappingExistingSelection; + /// A callback that should open the software keyboard when invoked. final VoidCallback openSoftwareKeyboard; @@ -607,7 +611,11 @@ class _IosDocumentTouchInteractorState extends State // The user tapped on an expanded selection. Toggle the toolbar and show // the software keyboard. _controlsController!.toggleToolbar(); - widget.openSoftwareKeyboard(); + + if (widget.openKeyboardWhenTappingExistingSelection) { + widget.openSoftwareKeyboard(); + } + return; } @@ -659,7 +667,7 @@ class _IosDocumentTouchInteractorState extends State _selectPosition(adjustedSelectionPosition); } - if (didTapOnExistingSelection) { + if (didTapOnExistingSelection && widget.openKeyboardWhenTappingExistingSelection) { // The user tapped on the existing selection. Show the software keyboard. // // If the user didn't tap on an existing selection, the software keyboard will diff --git a/super_editor/lib/src/default_editor/super_editor.dart b/super_editor/lib/src/default_editor/super_editor.dart index 43a9ac3ddd..07c961501f 100644 --- a/super_editor/lib/src/default_editor/super_editor.dart +++ b/super_editor/lib/src/default_editor/super_editor.dart @@ -882,6 +882,7 @@ class SuperEditorState extends State { document: editContext.document, getDocumentLayout: () => editContext.documentLayout, selection: editContext.composer.selectionNotifier, + openKeyboardWhenTappingExistingSelection: widget.selectionPolicies.openKeyboardWhenTappingExistingSelection, openSoftwareKeyboard: _openSoftareKeyboard, contentTapHandlers: _contentTapHandlers, scrollController: _scrollController, @@ -897,6 +898,7 @@ class SuperEditorState extends State { document: editContext.document, getDocumentLayout: () => editContext.documentLayout, selection: editContext.composer.selectionNotifier, + openKeyboardWhenTappingExistingSelection: widget.selectionPolicies.openKeyboardWhenTappingExistingSelection, openSoftwareKeyboard: _openSoftareKeyboard, contentTapHandlers: _contentTapHandlers, scrollController: _scrollController, @@ -1154,6 +1156,7 @@ class SuperEditorSelectionPolicies { const SuperEditorSelectionPolicies({ this.placeCaretAtEndOfDocumentOnGainFocus = true, this.restorePreviousSelectionOnGainFocus = true, + this.openKeyboardWhenTappingExistingSelection = true, this.clearSelectionWhenEditorLosesFocus = true, this.clearSelectionWhenImeConnectionCloses = true, }); @@ -1168,6 +1171,30 @@ class SuperEditorSelectionPolicies { /// focus, after having previous lost focus. final bool restorePreviousSelectionOnGainFocus; + /// {@template openKeyboardWhenTappingExistingSelection} + /// Whether the software keyboard should be opened when the user taps on the existing + /// selection. + /// + /// Defaults to `true`. + /// + /// Typically, when an editor has a selection, the software keyboard is already open. + /// However, in some cases, the user might want to temporarily close the keyboard. For + /// example, the user might replace the keyboard with a custom emoji picker panel. + /// + /// When the user is done with the temporary keyboard replacement, the user then wants to + /// open the keyboard again, so the user taps on the caret. If this property is `true` + /// then tapping on the caret will open the keyboard again. + /// + /// In other, similar cases, the user might want to be able to tap on the editor without + /// opening the keyboard. For example, the user might open a keyboard panel that can insert + /// various types of content. In that case, the user might want to move the caret to then + /// insert something from the panel. In this case, it's easy to accidentally tap on the + /// existing caret, which would then close the panel and open the keyboard. To avoid this + /// annoyance, this property can be set to `false`, in which case tapping on the caret won't + /// automatically open the keyboard. It's left to the app to re-open the keyboard when desired. + /// {@endtemplate} + final bool openKeyboardWhenTappingExistingSelection; + /// Whether the editor's selection should be removed when the editor loses /// all focus (not just primary focus). /// diff --git a/super_editor/lib/src/infrastructure/_logging.dart b/super_editor/lib/src/infrastructure/_logging.dart index d672a27b3f..fa650b5ffc 100644 --- a/super_editor/lib/src/infrastructure/_logging.dart +++ b/super_editor/lib/src/infrastructure/_logging.dart @@ -43,6 +43,7 @@ class LogNames { static const iosTextField = 'textfield.ios'; static const infrastructure = 'infrastructure'; + static const keyboardPanel = 'infrastructure.keyboardPanel'; static const longPressSelection = 'infrastructure.gestures.longPress'; static const scheduler = 'infrastructure.scheduler'; static const contentLayers = 'infrastructure.content_layers'; @@ -88,6 +89,7 @@ final iosTextFieldLog = logging.Logger(LogNames.iosTextField); final docGesturesLog = logging.Logger(LogNames.documentGestures); final infrastructureLog = logging.Logger(LogNames.infrastructure); +final keyboardPanelLog = logging.Logger(LogNames.keyboardPanel); final longPressSelectionLog = logging.Logger(LogNames.longPressSelection); final schedulerLog = logging.Logger(LogNames.scheduler); final contentLayersLog = logging.Logger(LogNames.contentLayers); diff --git a/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart index 8c6479312f..76bed4f0f4 100644 --- a/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart +++ b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart @@ -1,8 +1,12 @@ +import 'dart:math'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.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'; +import 'package:super_keyboard/super_keyboard.dart'; /// Scaffold that displays the given [contentBuilder], while also (optionally) displaying /// a toolbar docked to the top of the software keyboard, and/or a panel that appears @@ -31,7 +35,7 @@ import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; /// 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 { +class KeyboardPanelScaffold extends StatefulWidget { const KeyboardPanelScaffold({ super.key, required this.controller, @@ -43,7 +47,7 @@ class KeyboardPanelScaffold extends StatefulWidget { }); /// Controls the visibility of the keyboard toolbar, keyboard panel, and software keyboard. - final KeyboardPanelController controller; + final KeyboardPanelController controller; /// A [ValueListenable] that should notify this [KeyboardPanelScaffold] when the IME connects /// and disconnects. @@ -52,10 +56,10 @@ class KeyboardPanelScaffold extends StatefulWidget { 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; + final Widget Function(BuildContext context, PanelType? openPanel) toolbarBuilder; /// Builds the keyboard panel that's displayed in place of the software keyboard. - final WidgetBuilder keyboardPanelBuilder; + final Widget Function(BuildContext context, PanelType? openPanel) 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 @@ -67,57 +71,55 @@ class KeyboardPanelScaffold extends StatefulWidget { /// 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; + final Widget Function(BuildContext context, PanelType? openPanel) contentBuilder; @override - State createState() => _KeyboardPanelScaffoldState(); + State> createState() => _KeyboardPanelScaffoldState(); } -class _KeyboardPanelScaffoldState extends State +class _KeyboardPanelScaffoldState extends State> with SingleTickerProviderStateMixin - implements KeyboardPanelScaffoldDelegate { - /// The maximum bottom insets that have been observed since the keyboard started expanding. - /// - /// This is reset when both the software keyboard and the keyboard panel are closed. - double _maxBottomInsets = 0.0; + implements KeyboardPanelScaffoldDelegate { + /// Whether we've run at least one didChangeDependencies, which is initially + /// used to check for existing bottom insets. + bool _didInitializeViewInsets = false; - /// 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: + /// The best guess of the height of the fully open software keyboard. /// - /// - 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. + /// The OS doesn't report this info. We observe the bottom insets and retain + /// the tallest value that we see. /// - /// - When the software keyboard is closed and the user closes the keyboard panel, this value - /// is animated from the latest [_maxBottomInsets] to zero. - /// - /// - Otherwise, it is equal to [_maxBottomInsets]. - final ValueNotifier _keyboardHeight = ValueNotifier(0.0); + /// Note: There may be situations in which an "open" keyboard corresponds to + /// multiple possible heights. For example, on an iPad, iOS reports an "open" + /// keyboard when the software keyboard is up, as well as when the small "minimized" + /// keyboard toolbar is visible. The minimized version is only 69 pixels tall. + double _bestGuessMaxKeyboardHeight = 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; - bool _didInitializeViewInsets = false; + /// The current visual state of the keyboard, e.g., closed, opening, open, closing. + KeyboardState _keyboardState = KeyboardState.closed; - /// Whether or not we believe that the keyboard is currently open (or opening). - bool _isKeyboardOpen = false; + /// The height of the software keyboard at this moment. + double _currentKeyboardHeight = 0.0; - /// 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; + /// The height of the visible panel at this moment. + late final AnimationController _panelHeightController; + late Animation _panelHeight; + + /// The currently visible panel. + PanelType? _activePanel; + + /// The current bottom spacing, which might be equal to a panel height, or the + /// current keyboard height, or it might be an intermediate spacing as we switch + /// between a panel and keyboard. + final _currentBottomSpacing = ValueNotifier(0.0); /// Shows/hides the [OverlayPortal] containing the keyboard panel and above-keyboard panel. final OverlayPortalController _overlayPortalController = OverlayPortalController(); bool get _wantsToShowToolbar => widget.controller.toolbarVisibility == KeyboardToolbarVisibility.visible || - (widget.controller.toolbarVisibility == KeyboardToolbarVisibility.auto && widget.isImeConnected.value); + (widget.controller.toolbarVisibility == KeyboardToolbarVisibility.auto && + (widget.isImeConnected.value || wantsToShowKeyboardPanel)); final _toolbarKey = GlobalKey(); @@ -139,16 +141,18 @@ class _KeyboardPanelScaffoldState extends State return true; }()); - _panelExitAnimation = AnimationController( + _panelHeightController = AnimationController( vsync: this, duration: const Duration(milliseconds: 250), - ); - _panelExitAnimation.addListener(_updatePanelForExitAnimation); + )..addListener(_onPanelHeightChange); + _updateMaxPanelHeight(); widget.controller.attach(this); widget.isImeConnected.addListener(_onImeConnectionChange); + SuperKeyboard.instance.state.addListener(_onKeyboardStateChange); + _overlayPortalController.show(); onNextFrame((_) { // Do initial safe area report to our ancestor keyboard safe area widget, @@ -163,18 +167,16 @@ class _KeyboardPanelScaffoldState extends State _ancestorSafeArea = KeyboardScaffoldSafeArea.maybeOf(context); if (!_didInitializeViewInsets) { - // Initialize our view insets cache with the existing ancestor value so - // that if the keyboard happens to already be raised, we don't treat the - // situation as the keyboard starting to come up. - _latestViewInsets = MediaQuery.viewInsetsOf(context); _didInitializeViewInsets = true; + _bestGuessMaxKeyboardHeight = MediaQuery.viewInsetsOf(context).bottom; + _updateMaxPanelHeight(); } _updateKeyboardHeightForCurrentViewInsets(); } @override - void didUpdateWidget(KeyboardPanelScaffold oldWidget) { + void didUpdateWidget(KeyboardPanelScaffold oldWidget) { super.didUpdateWidget(oldWidget); if (widget.controller != oldWidget.controller) { oldWidget.controller.detach(); @@ -185,6 +187,10 @@ class _KeyboardPanelScaffoldState extends State oldWidget.isImeConnected.removeListener(_onImeConnectionChange); widget.isImeConnected.addListener(_onImeConnectionChange); } + + if (widget.fallbackPanelHeight != oldWidget.fallbackPanelHeight) { + _updateMaxPanelHeight(); + } } @override @@ -200,12 +206,15 @@ class _KeyboardPanelScaffoldState extends State void dispose() { _ancestorSafeArea?.geometry = const KeyboardSafeAreaGeometry(); + SuperKeyboard.instance.state.removeListener(_onKeyboardStateChange); + widget.isImeConnected.removeListener(_onImeConnectionChange); widget.controller.detach(); - _panelExitAnimation.removeListener(_updatePanelForExitAnimation); - _panelExitAnimation.dispose(); + // _panelAnimation.removeListener(_updatePanelForExitAnimation); + _panelHeightController.removeListener(_onPanelHeightChange); + _panelHeightController.dispose(); if (_overlayPortalController.isShowing) { // WARNING: We can only call `hide()` if `isShowing` is `true`. If we blindly @@ -214,9 +223,44 @@ class _KeyboardPanelScaffoldState extends State _overlayPortalController.hide(); } + _listeners.clear(); + super.dispose(); } + void _onKeyboardStateChange() { + _keyboardState = SuperKeyboard.instance.state.value; + + // Note: The following post frame callback shouldn't be necessary. + // We should be able to look up our ancestor MediaQuery immediately. + // However, it was found when writing tests that at the end of a test + // the order in which Flutter disposes of widgets was resulting in an + // attempt to access a disposed MediaQuery. I think this is probably a + // bug in Flutter somewhere. To work around it, we do the update at the + // end of the current frame, and we check that we're still mounted. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + + _updateKeyboardHeightForCurrentViewInsets(); + }); + } + + void _updateMaxPanelHeight() { + _panelHeight = Tween( + begin: 0.0, + end: _bestGuessMaxKeyboardHeight > 100 ? _bestGuessMaxKeyboardHeight : widget.fallbackPanelHeight, + ) // + .chain(CurveTween(curve: Curves.easeInOut)) + .animate(_panelHeightController); + } + + void _onPanelHeightChange() { + _updateSafeArea(); + _currentBottomSpacing.value = max(_panelHeight.value, _currentKeyboardHeight); + } + @override void onAttached(SoftwareKeyboardController softwareKeyboardController) { _softwareKeyboardController = softwareKeyboardController; @@ -299,16 +343,8 @@ class _KeyboardPanelScaffoldState extends State 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(); - } - } + bool get isSoftwareKeyboardOpen => _wantsToShowSoftwareKeyboard; /// Shows the software keyboard, if it's hidden. @override @@ -317,6 +353,9 @@ class _KeyboardPanelScaffoldState extends State _wantsToShowKeyboardPanel = false; _wantsToShowSoftwareKeyboard = true; _softwareKeyboardController!.open(); + + // Notify delegate listeners. + notifyListeners(); }); } @@ -326,6 +365,9 @@ class _KeyboardPanelScaffoldState extends State setState(() { _wantsToShowSoftwareKeyboard = false; _softwareKeyboardController!.hide(); + + // Notify delegate listeners. + notifyListeners(); }); _maybeAnimatePanelClosed(); @@ -335,14 +377,35 @@ class _KeyboardPanelScaffoldState extends State bool get wantsToShowKeyboardPanel => _wantsToShowKeyboardPanel; bool _wantsToShowKeyboardPanel = false; + @override + bool get isKeyboardPanelOpen => _wantsToShowKeyboardPanel; + + @override + PanelType? get openPanel => _activePanel; + /// Shows the keyboard panel, if it's closed, and hides (doesn't close) the /// software keyboard, if it's open. @override - void showKeyboardPanel() { + void showKeyboardPanel(PanelType panel) { setState(() { _wantsToShowKeyboardPanel = true; _wantsToShowSoftwareKeyboard = false; + _activePanel = panel; + + if (_keyboardState == KeyboardState.open) { + // The keyboard is fully open. We'd like for the panel to immediately + // appear behind the keyboard as it closes, so that we don't have a + // bunch of jumping around for the widgets mounted to the top of the + // keyboard. + _panelHeightController.value = 1.0; + } else { + _panelHeightController.forward(); + } + _softwareKeyboardController!.hide(); + + // Notify delegate listeners. + notifyListeners(); }); } @@ -350,7 +413,16 @@ class _KeyboardPanelScaffoldState extends State @override void hideKeyboardPanel() { setState(() { + // Close panel. _wantsToShowKeyboardPanel = false; + _activePanel = null; + _panelHeightController.reverse(); + + // Open the keyboard. + _softwareKeyboardController!.open(); + + // Notify delegate listeners. + notifyListeners(); }); } @@ -361,73 +433,111 @@ class _KeyboardPanelScaffoldState extends State setState(() { _wantsToShowKeyboardPanel = false; _wantsToShowSoftwareKeyboard = false; + _activePanel = null; _softwareKeyboardController!.close(); + + // Notify delegate listeners. + notifyListeners(); }); - _maybeAnimatePanelClosed(); + _panelHeightController.reverse(); } void _maybeAnimatePanelClosed() { - if (_wantsToShowKeyboardPanel || _wantsToShowSoftwareKeyboard || _latestViewInsets.bottom != 0.0) { + if (_wantsToShowKeyboardPanel || _wantsToShowSoftwareKeyboard || _currentKeyboardHeight != 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); + _panelHeightController.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.viewInsetsOf(context); - final newBottomInset = newInsets.bottom; - final isKeyboardOpening = newBottomInset > _latestViewInsets.bottom; - final isKeyboardCollapsing = newBottomInset < _latestViewInsets.bottom; - - if (_isKeyboardOpen && isKeyboardCollapsing) { - // The keyboard went from open to closed. Update our cached state. - _isKeyboardOpen = false; - } else if (!_isKeyboardOpen && isKeyboardOpening) { - // The keyboard went from closed to open. If there's an open panel, close it. - _isKeyboardOpen = true; - widget.controller.hideKeyboardPanel(); + final newBottomInset = MediaQuery.viewInsetsOf(context).bottom; + _currentKeyboardHeight = newBottomInset; + + switch (_keyboardState) { + case KeyboardState.open: + if (newBottomInset >= _bestGuessMaxKeyboardHeight) { + // Note: On iOS "open" doesn't necessarily mean fully open. I've found + // that rapidly opening and closing the keyboard results in an "open" + // message despite the fact that the keyboard didn't make it all the + // way up. + _bestGuessMaxKeyboardHeight = newBottomInset; + } + + if (_wantsToShowSoftwareKeyboard) { + // Now that the keyboard is fully open, and we want to show the keyboard, + // ensure that any previously visible panel is gone. We only want to do + // this if the keyboard fully opens. Otherwise, this state probably + // represents a rapid toggle between the keyboard and a panel. In that case, + // leave the panel alone. + _panelHeightController.value = 0; + _wantsToShowKeyboardPanel = false; + _activePanel = null; + } + + _updateMaxPanelHeight(); + + // Notify delegate listeners. + notifyListeners(); + + break; + case KeyboardState.closed: + // It was found on the iPad simulator that it was possible to close the minimized keyboard, + // receive a message that the keyboard was closed, but still have bottom insets that reported + // the height of the minimized keyboard. To hack around that, we explicitly set the keyboard + // height to zero, when closed. + if (_currentKeyboardHeight > 0) { + _currentKeyboardHeight = 0.0; + onNextFrame((_) => _updateSafeArea()); + } + break; + case KeyboardState.opening: + // The keyboard is changing size. Update our safe area. + onNextFrame((_) => _updateSafeArea()); + break; + case KeyboardState.closing: + if (!wantsToShowKeyboardPanel) { + // The keyboard is collapsing and we don't want the keyboard panel to be visible. + // Follow the keyboard back down. + _panelHeightController + ..stop() + ..value = newBottomInset / _bestGuessMaxKeyboardHeight; + } + + // The keyboard is changing size. Update our safe area. + onNextFrame((_) => _updateSafeArea()); + break; } - _latestViewInsets = newInsets; + _currentBottomSpacing.value = max(_panelHeight.value, _currentKeyboardHeight); - if (newBottomInset > _maxBottomInsets) { - // The keyboard is expanding. - _maxBottomInsets = newBottomInset; - _keyboardHeight.value = _maxBottomInsets; - onNextFrame((ts) => _updateSafeArea()); - return; - } + setState(() { + // Re-build with the various property changes we made above. + }); + } - if (isKeyboardCollapsing && !_wantsToShowKeyboardPanel) { - // The keyboard is collapsing and we don't want the keyboard panel to be visible. - // Follow the keyboard back down. - _maxBottomInsets = newBottomInset; - _keyboardHeight.value = _maxBottomInsets; - onNextFrame((ts) => _updateSafeArea()); - return; - } + final _listeners = {}; - onNextFrame((ts) => _updateSafeArea()); - } + @override + bool get hasListeners => _listeners.isNotEmpty; - /// Animates the panel height when the software keyboard is closed and the user wants - /// to close the keyboard panel. - void _updatePanelForExitAnimation() { - setState(() { - _keyboardHeight.value = _maxBottomInsets * Curves.easeInQuad.transform(_panelExitAnimation.value); - onNextFrame((ts) => _updateSafeArea()); - if (_panelExitAnimation.status == AnimationStatus.dismissed) { - // The panel has been fully collapsed. Reset the max known bottom insets. - _maxBottomInsets = 0.0; - } - }); + @override + void addListener(VoidCallback listener) => _listeners.add(listener); + + @override + void removeListener(VoidCallback listener) => _listeners.remove(listener); + + @override + void notifyListeners() { + for (final listener in _listeners) { + listener(); + } } /// Update the bottom insets of the enclosing [KeyboardScaffoldSafeArea]. @@ -443,51 +553,46 @@ class _KeyboardPanelScaffoldState extends State : MediaQuery.paddingOf(context).bottom; final toolbarSize = (_toolbarKey.currentContext?.findRenderObject() as RenderBox?)?.size; + final bottomInsets = _currentBottomSpacing.value + (toolbarSize?.height ?? 0); + _ancestorSafeArea!.geometry = _ancestorSafeArea!.geometry.copyWith( - bottomInsets: _wantsToShowKeyboardPanel // - ? _keyboardPanelHeight + (toolbarSize?.height ?? 0) - : _keyboardHeight.value + (toolbarSize?.height ?? 0), + bottomInsets: bottomInsets, bottomPadding: bottomPadding, ); } - double get _keyboardPanelHeight { - return _wantsToShowKeyboardPanel // - ? _keyboardHeight.value < 100 // - // ^ 100px is an arbitrary dividing point. Above that, we believe that - // we have recorded the real keyboard height. Below that, we may have - // only recorded the bottom notch or a partial keyboard height. In that - // case, use the fallback height that's hard-coded. - ? widget.fallbackPanelHeight - : _keyboardHeight.value - : 0.0; - } - @override Widget build(BuildContext context) { - final wantsToShowKeyboardPanel = _wantsToShowKeyboardPanel || + final shouldShowKeyboardPanel = 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 // - // ^ 100px is an arbitrary dividing point. Above that, we believe that - // we have recorded the real keyboard height. Below that, we may have - // only recorded the bottom notch or a partial keyboard height. In that - // case, use the fallback height that's hard-coded. - ? widget.fallbackPanelHeight - : 0.0 - : 0.0; + (wantsToShowSoftwareKeyboard && _keyboardState != KeyboardState.open); + + assert(() { + keyboardPanelLog.fine(''' +Building keyboard scaffold + - keyboard state: $_keyboardState + - wants to show toolbar? $_wantsToShowToolbar + - wants to show software keyboard? $wantsToShowSoftwareKeyboard + - best-guess keyboard height: $_bestGuessMaxKeyboardHeight + - current keyboard height: $_currentKeyboardHeight + - wants to show keyboard panel? $wantsToShowKeyboardPanel + - should show keyboard panel? $shouldShowKeyboardPanel + - active panel: $_activePanel + - current panel animation progress: ${_panelHeightController.value}, animation height: ${_panelHeight.value} + - current bottom spacing: ${_currentBottomSpacing.value}'''); + + return true; + }()); return OverlayPortal( controller: _overlayPortalController, overlayChildBuilder: (context) { return ValueListenableBuilder( - valueListenable: _keyboardHeight, + valueListenable: _currentBottomSpacing, builder: (context, currentHeight, child) { - if (!_wantsToShowToolbar && !wantsToShowKeyboardPanel) { + if (!_wantsToShowToolbar && !shouldShowKeyboardPanel) { return const SizedBox.shrink(); } @@ -509,14 +614,22 @@ class _KeyboardPanelScaffoldState extends State key: _toolbarKey, child: widget.toolbarBuilder( context, - _wantsToShowKeyboardPanel, + _activePanel, ), ), - SizedBox( - height: !_wantsToShowKeyboardPanel || _keyboardHeight.value > 100 - ? _keyboardHeight.value - : fakeKeyboardHeight, - child: wantsToShowKeyboardPanel ? widget.keyboardPanelBuilder(context) : null, + // Spacer that pushes the toolbar up above the current bottom spacing, + // whether that's the software keyboard, or a panel. + AnimatedBuilder( + animation: _currentBottomSpacing, + builder: (context, child) { + return SizedBox( + height: _currentBottomSpacing.value, + child: child, + ); + }, + // In the case that we want to display a panel, display it here, + // in the current bottom space below the toolbar. + child: shouldShowKeyboardPanel ? widget.keyboardPanelBuilder(context, _activePanel) : null, ), ], ), @@ -526,14 +639,14 @@ class _KeyboardPanelScaffoldState extends State }, child: widget.contentBuilder( context, - _wantsToShowKeyboardPanel, + _activePanel, ), ); } } /// Shows and hides the keyboard panel and software keyboard. -class KeyboardPanelController { +class KeyboardPanelController { KeyboardPanelController( this._softwareKeyboardController, ); @@ -544,7 +657,7 @@ class KeyboardPanelController { final SoftwareKeyboardController _softwareKeyboardController; - KeyboardPanelScaffoldDelegate? _delegate; + KeyboardPanelScaffoldDelegate? _delegate; /// Whether this controller is currently attached to a delegate that /// knows how to show a toolbar, and open/close the software keyboard @@ -553,7 +666,7 @@ class KeyboardPanelController { /// 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) { + void attach(KeyboardPanelScaffoldDelegate delegate) { editorImeLog.finer("[KeyboardPanelController] - Attaching to delegate: $delegate"); _delegate = delegate; _delegate!.onAttached(_softwareKeyboardController); @@ -582,11 +695,12 @@ class KeyboardPanelController { /// Hides the toolbar that's mounted to the top of the keyboard area. void hideToolbar() => _delegate?.hideToolbar(); - /// Opens the keyboard panel if the keyboard is open, or opens the keyboard - /// if the keyboard panel is open. - void toggleSoftwareKeyboardWithPanel() { - _delegate?.toggleSoftwareKeyboardWithPanel(); - } + /// Whether the delegate currently wants a keyboard panel to be open. + /// + /// This is expressed as "want" because the keyboard panel has transitory states, + /// like opening and closing. Therefore, this property doesn't reflect actual + /// visibility. + bool get isSoftwareKeyboardOpen => _delegate?.isKeyboardPanelOpen ?? false; /// Shows the software keyboard, if it's hidden. void showSoftwareKeyboard() { @@ -598,11 +712,18 @@ class KeyboardPanelController { _delegate?.hideSoftwareKeyboard(); } + /// Whether the delegate currently wants a keyboard panel to be open. + /// + /// This is expressed as "want" because the keyboard panel has transitory states, + /// like opening and closing. Therefore, this property doesn't reflect actual + /// visibility. + bool get isKeyboardPanelOpen => _delegate?.isKeyboardPanelOpen ?? false; + + PanelType? get openPanel => _delegate?.openPanel; + /// Shows the keyboard panel, if it's closed, and hides (doesn't close) the /// software keyboard, if it's open. - void showKeyboardPanel() { - _delegate?.showKeyboardPanel(); - } + void showKeyboardPanel(PanelType panel) => _delegate?.showKeyboardPanel(panel); /// Hides the keyboard panel, if it's open. void hideKeyboardPanel() { @@ -616,7 +737,7 @@ class KeyboardPanelController { } } -abstract interface class KeyboardPanelScaffoldDelegate { +abstract interface class KeyboardPanelScaffoldDelegate implements ChangeNotifier { /// Called on this delegate by the [KeyboardPanelController] when the controller /// attaches to the delegate. /// @@ -643,9 +764,12 @@ abstract interface class KeyboardPanelScaffoldDelegate { /// 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(); + /// Whether this delegate currently wants the software keyboard to be open. + /// + /// This is expressed as "want" because the keyboard has transitory states, + /// like opening and closing. Therefore, this property doesn't reflect actual + /// visibility. + bool get isSoftwareKeyboardOpen; /// Shows the software keyboard, if it's hidden. void showSoftwareKeyboard(); @@ -653,9 +777,18 @@ abstract interface class KeyboardPanelScaffoldDelegate { /// Hides (doesn't close) the software keyboard, if it's open. void hideSoftwareKeyboard(); + /// Whether this delegate currently wants a keyboard panel to be open. + /// + /// This is expressed as "want" because the keyboard panel has transitory states, + /// like opening and closing. Therefore, this property doesn't reflect actual + /// visibility. + bool get isKeyboardPanelOpen; + + PanelType? get openPanel; + /// Shows the keyboard panel, if it's closed, and hides (doesn't close) the /// software keyboard, if it's open. - void showKeyboardPanel(); + void showKeyboardPanel(PanelType panel); /// Hides the keyboard panel, if it's open. void hideKeyboardPanel(); @@ -729,7 +862,7 @@ class _KeyboardScaffoldSafeAreaState extends State // // First, it's possible that this safe area sits beneath another safe area. In that // case, we defer to the ancestor safe area. This makes it possible to create a keyboard - // safe area in one subtree, and communicate that safe are to another subtree, by + // safe area in one subtree, and communicate that safe area to another subtree, by // sharing an ancestor. For example, consider a widget tree where a chat editor sits in // a Stack, and the content sits behind that editor, in the same Stack. In that case, // we want to apply a keyboard safe area to the content, but that content is a cousin diff --git a/super_editor/pubspec.yaml b/super_editor/pubspec.yaml index 602d65e1c7..e3e26f854d 100644 --- a/super_editor/pubspec.yaml +++ b/super_editor/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: linkify: ^5.0.0 logging: ^1.3.0 super_text_layout: ^0.1.17 + super_keyboard: ^0.1.0 url_launcher: ^6.3.1 uuid: ^4.5.1 overlord: ^0.0.3+5 diff --git a/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart b/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart index 2663dd72e1..0cb4369939 100644 --- a/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart +++ b/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test_runners/flutter_test_runners.dart'; -import 'package:golden_toolkit/golden_toolkit.dart'; import 'package:super_editor/src/test/super_editor_test/supereditor_robot.dart'; import 'package:super_editor/super_editor.dart'; @@ -82,7 +81,7 @@ void main() { await tester.placeCaretInParagraph('1', 0); // Request to show the keyboard panel. - controller.showKeyboardPanel(); + controller.showKeyboardPanel(_Panel.panel1); await tester.pumpAndSettle(); // Hide both the keyboard panel and the software keyboard. @@ -124,7 +123,7 @@ void main() { await tester.placeCaretInParagraph('1', 0); // Request to show the keyboard panel. - controller.showKeyboardPanel(); + controller.showKeyboardPanel(_Panel.panel1); await tester.pumpAndSettle(); // Ensure the keyboard panel is visible. @@ -149,7 +148,7 @@ void main() { await tester.placeCaretInParagraph('1', 0); // Request to show the keyboard panel and let the entrance animation run. - controller.showKeyboardPanel(); + controller.showKeyboardPanel(_Panel.panel1); await tester.pumpAndSettle(); // Ensure the keyboard panel has the same size as the software keyboard. @@ -165,7 +164,7 @@ void main() { ); }); - testWidgetsOnMobilePhone('hides the panel when toggling the keyboard', (tester) async { + testWidgetsOnMobilePhone('hides the panel when showing the keyboard', (tester) async { final softwareKeyboardController = SoftwareKeyboardController(); final controller = KeyboardPanelController(softwareKeyboardController); @@ -175,7 +174,7 @@ void main() { softwareKeyboardController: softwareKeyboardController, ); - // Request to show the above-keyboard panel. + // Request to show the toolbar. controller.showToolbar(); await tester.pump(); @@ -183,20 +182,20 @@ void main() { await tester.placeCaretInParagraph('1', 0); // Request to show the keyboard panel and let the entrance animation run. - controller.showKeyboardPanel(); + controller.showKeyboardPanel(_Panel.panel1); 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(); + controller.showSoftwareKeyboard(); 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. + // Ensure the toolbar sits immediately above the keyboard. expect( tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, equals(tester.getSize(find.byType(MaterialApp)).height - _expandedPhoneKeyboardHeight), @@ -221,7 +220,7 @@ void main() { await tester.placeCaretInParagraph('1', 0); // Request to show the keyboard panel and let the entrance animation run. - controller.showKeyboardPanel(); + controller.showKeyboardPanel(_Panel.panel1); await tester.pumpAndSettle(); // Ensure the keyboard panel is visible. @@ -258,7 +257,7 @@ void main() { await tester.placeCaretInParagraph('1', 0); // Request to show the keyboard panel and let the entrance animation run. - controller.showKeyboardPanel(); + controller.showKeyboardPanel(_Panel.panel1); await tester.pumpAndSettle(); // Ensure the keyboard panel is visible. @@ -290,7 +289,7 @@ void main() { await tester.placeCaretInParagraph('1', 0); // Request to show the keyboard panel and let the entrance animation run. - controller.showKeyboardPanel(); + controller.showKeyboardPanel(_Panel.panel1); await tester.pumpAndSettle(); // Ensure the keyboard panel is visible. @@ -327,7 +326,7 @@ void main() { await tester.placeCaretInParagraph('1', 0); // Request to show the keyboard panel and let the entrance animation run. - controller.showKeyboardPanel(); + controller.showKeyboardPanel(_Panel.panel1); await tester.pumpAndSettle(); // Ensure the keyboard panel is visible. @@ -359,7 +358,7 @@ void main() { ); // Request to show the keyboard panel and let the entrance animation run. - controller.showKeyboardPanel(); + controller.showKeyboardPanel(_Panel.panel1); await tester.pumpAndSettle(); // Ensure the keyboard panel is visible and positioned at the bottom of the screen. @@ -410,7 +409,7 @@ void main() { await tester.placeCaretInParagraph('1', 0); // Request to show the keyboard panel and let the entrance animation run. - controller.showKeyboardPanel(); + controller.showKeyboardPanel(_Panel.panel1); await tester.pumpAndSettle(); // Ensure the keyboard panel is visible. @@ -442,7 +441,7 @@ void main() { ); // Request to show the keyboard panel and let the entrance animation run. - controller.showKeyboardPanel(); + controller.showKeyboardPanel(_Panel.panel1); await tester.pumpAndSettle(); // Ensure the keyboard panel is visible and positioned at the bottom of the screen. @@ -493,14 +492,14 @@ void main() { final contentHeightWithNoKeyboard = tester.getSize(find.byKey(_chatPageKey)).height; // Show a keyboard panel (not the keyboard). - controller.showKeyboardPanel(); + controller.showKeyboardPanel(_Panel.panel1); await tester.pumpAndSettle(); // Record the height of the content now that a keyboard panel is open. final contentHeightWithKeyboardPanelOpen = tester.getSize(find.byKey(_chatPageKey)).height; // Ensure that the content is pushed up above the keyboard panel. - expect(contentHeightWithNoKeyboard - contentHeightWithKeyboardPanelOpen, _keyboardPanelHeight); + expect(contentHeightWithNoKeyboard - contentHeightWithKeyboardPanelOpen, _toolbarHeight + _keyboardPanelHeight); }); testWidgetsOnMobilePhone('removes bottom insets when focus leaves editor', (tester) async { @@ -518,14 +517,14 @@ void main() { final contentHeightWithNoKeyboard = tester.getSize(find.byKey(_chatPageKey)).height; // Show a keyboard panel (not the keyboard). - controller.showKeyboardPanel(); + controller.showKeyboardPanel(_Panel.panel1); await tester.pumpAndSettle(); // Record the height of the content now that a keyboard panel is open. final contentHeightWithKeyboardPanelOpen = tester.getSize(find.byKey(_chatPageKey)).height; // Ensure that the content is pushed up above the keyboard panel. - expect(contentHeightWithNoKeyboard - contentHeightWithKeyboardPanelOpen, _keyboardPanelHeight); + expect(contentHeightWithNoKeyboard - contentHeightWithKeyboardPanelOpen, _toolbarHeight + _keyboardPanelHeight); // Switch to other tab. await tester.tap(find.byKey(_accountTabKey)); @@ -693,10 +692,10 @@ Widget _buildChatPage( toolbarBuilder: (context, isKeyboardPanelVisible) => Container( key: _aboveKeyboardToolbarKey, height: 54, - color: Colors.blue, + color: Colors.green, ), fallbackPanelHeight: _keyboardPanelHeight, - keyboardPanelBuilder: (context) => const SizedBox.expand( + keyboardPanelBuilder: (context, panel) => const SizedBox.expand( child: ColoredBox( key: _keyboardPanelKey, color: Colors.red, @@ -904,6 +903,9 @@ void testWidgetsOnAndroidTablet( ); } +// Height of the toolbar that sits above the keyboard/panel. +const _toolbarHeight = 54.0; + // Simulated height of a fully visible phone keyboard. We specify this because // there's no real window in a widget test, and therefore no real keyboard. const _expandedPhoneKeyboardHeight = 300.0; @@ -931,3 +933,8 @@ const _minimizedAndroidTabletKeyboardHeight = 62.0; const _aboveKeyboardToolbarKey = ValueKey('toolbar'); const _keyboardPanelKey = ValueKey('keyboardPanel'); + +enum _Panel { + panel1, + panel2; +} diff --git a/super_editor/test/super_editor/supereditor_test_tools.dart b/super_editor/test/super_editor/supereditor_test_tools.dart index e8e512c55f..f22632a054 100644 --- a/super_editor/test/super_editor/supereditor_test_tools.dart +++ b/super_editor/test/super_editor/supereditor_test_tools.dart @@ -7,6 +7,7 @@ import 'package:mockito/mockito.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_editor/super_editor_test.dart'; import 'package:super_editor_markdown/super_editor_markdown.dart'; +import 'package:super_keyboard/super_keyboard_test.dart'; import 'package:text_table/text_table.dart'; import '../test_tools_user_input.dart'; diff --git a/super_editor/test/test_tools_user_input.dart b/super_editor/test/test_tools_user_input.dart index efbfaddbcd..c416d01b5e 100644 --- a/super_editor/test/test_tools_user_input.dart +++ b/super_editor/test/test_tools_user_input.dart @@ -2,7 +2,6 @@ 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'; @@ -35,170 +34,6 @@ class InputAndGestureTuple { } } -/// A widget that simulates the software keyboard appearance and disappearance. -/// -/// This works by listening to platform messages that show/hide the software keyboard -/// and animating the `MediaQuery` bottom insets to reflect the height of the keyboard. -/// -/// Place this widget above the `Scaffold` in the widget tree. -class SoftwareKeyboardHeightSimulator extends StatefulWidget { - const SoftwareKeyboardHeightSimulator({ - required this.tester, - this.isEnabled = true, - this.enableForAllPlatforms = false, - this.keyboardHeight = 300, - this.animateKeyboard = false, - required this.child, - }); - - /// Flutter's [WidgetTester], which is used to intercept platform messages - /// about opening/closing the keyboard. - final WidgetTester tester; - - /// Whether or not to enable the simulated software keyboard insets. - /// - /// This property is provided so that clients don't need to conditionally add/remove - /// this widget from the tree. Instead this flag can be flipped, as needed. - final bool isEnabled; - - /// Whether to simulate software keyboard insets for all platforms (`true`), or whether to - /// only simulate software keyboard insets for mobile platforms, e.g., Android, iOS (`false`). - final bool enableForAllPlatforms; - - /// The vertical space, in logical pixels, to occupy at the bottom of the screen to simulate the appearance - /// of a keyboard. - final double keyboardHeight; - - /// Whether to simulate keyboard open/closing animations. - /// - /// These animations change the keyboard insets over time, similar to how a real - /// software keyboard slides up/down. However, this also means that clients need to - /// `pumpAndSettle()` to ensure the animation is complete. If you want to avoid `pumpAndSettle()` - /// and you don't care about the animation, then pass `false` to disable the animations. - final bool animateKeyboard; - - final Widget child; - - @override - State createState() => _SoftwareKeyboardHeightSimulatorState(); -} - -class _SoftwareKeyboardHeightSimulatorState extends State - with SingleTickerProviderStateMixin { - late AnimationController _animationController; - - @override - void initState() { - super.initState(); - _animationController = AnimationController( - duration: const Duration(milliseconds: 600), - vsync: this, - ); - - if (widget.isEnabled) { - _setupPlatformMethodInterception(); - } - } - - @override - void didUpdateWidget(covariant SoftwareKeyboardHeightSimulator oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.tester != oldWidget.tester && widget.isEnabled) { - _setupPlatformMethodInterception(); - } - } - - @override - void dispose() { - _animationController.dispose(); - super.dispose(); - } - - void _showKeyboard() { - if (_animationController.isForwardOrCompleted) { - // The keyboard is either fully visible or animating its entrance. - return; - } - - if (!widget.animateKeyboard) { - _animationController.value = 1.0; - return; - } - - _animationController.forward(); - } - - void _hideKeyboard() { - if (const [AnimationStatus.dismissed, AnimationStatus.reverse].contains(_animationController.status)) { - // The keyboard is either hidden or animating its exit. - return; - } - - if (!widget.animateKeyboard) { - _animationController.value = 0.0; - return; - } - - _animationController.reverse(); - } - - void _setupPlatformMethodInterception() { - widget.tester.interceptChannel(SystemChannels.textInput.name) // - ..interceptMethod( - 'TextInput.show', - (methodCall) { - _showKeyboard(); - return null; - }, - ) - ..interceptMethod( - 'TextInput.setClient', - (methodCall) { - _showKeyboard(); - return null; - }, - ) - ..interceptMethod( - 'TextInput.clearClient', - (methodCall) { - _hideKeyboard(); - return null; - }, - ) - ..interceptMethod( - 'TextInput.hide', - (methodCall) { - _hideKeyboard(); - return null; - }, - ); - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _animationController, - builder: (context, _) { - final realMediaQuery = MediaQuery.of(context); - final isRelevantPlatform = widget.enableForAllPlatforms || - (defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS); - final shouldSimulate = widget.isEnabled && isRelevantPlatform; - - return MediaQuery( - data: realMediaQuery.copyWith( - viewInsets: realMediaQuery.viewInsets.copyWith( - bottom: shouldSimulate - ? widget.keyboardHeight * _animationController.value - : realMediaQuery.viewInsets.bottom, - ), - ), - child: widget.child, - ); - }, - ); - } -} - /// A [TextInputConnection] that tracks the number of content updates, to verify /// within tests. class ImeConnectionWithUpdateCount extends TextInputConnectionDecorator { diff --git a/super_keyboard/analysis_options.yaml b/super_keyboard/analysis_options.yaml index a5744c1cfb..ce17390693 100644 --- a/super_keyboard/analysis_options.yaml +++ b/super_keyboard/analysis_options.yaml @@ -1,4 +1,8 @@ include: package:flutter_lints/flutter.yaml +analyzer: + exclude: + - doc/** + # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options